From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../xpcshell/test_AboutHomeStartupCacheChild.js | 33 + .../xpcshell/test_AboutHomeStartupCacheWorker.js | 255 ++ .../newtab/test/xpcshell/test_AboutNewTab.js | 363 +++ .../test/xpcshell/test_AboutWelcomeAttribution.js | 69 + .../test/xpcshell/test_AboutWelcomeTelemetry.js | 90 + .../xpcshell/test_AboutWelcomeTelemetry_glean.js | 238 ++ .../newtab/test/xpcshell/test_HighlightsFeed.js | 1402 ++++++++ .../newtab/test/xpcshell/test_PlacesFeed.js | 1812 +++++++++++ .../components/newtab/test/xpcshell/test_Store.js | 453 +++ .../newtab/test/xpcshell/test_TelemetryFeed.js | 3285 +++++++++++++++++++ .../newtab/test/xpcshell/test_TopSitesFeed.js | 3397 ++++++++++++++++++++ .../test/xpcshell/test_TopSitesFeed_glean.js | 2023 ++++++++++++ .../newtab/test/xpcshell/topstories.json | 53 + .../components/newtab/test/xpcshell/xpcshell.toml | 34 + 14 files changed, 13507 insertions(+) create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutNewTab.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js create mode 100644 browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js create mode 100644 browser/components/newtab/test/xpcshell/test_HighlightsFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_PlacesFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_Store.js create mode 100644 browser/components/newtab/test/xpcshell/test_TelemetryFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_TopSitesFeed.js create mode 100644 browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js create mode 100644 browser/components/newtab/test/xpcshell/topstories.json create mode 100644 browser/components/newtab/test/xpcshell/xpcshell.toml (limited to 'browser/components/newtab/test/xpcshell') diff --git a/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js new file mode 100644 index 0000000000..ce1ee48caa --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheChild.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AboutHomeStartupCacheChild } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTabService.sys.mjs" +); + +/** + * Tests that AboutHomeStartupCacheChild will terminate its PromiseWorker + * on memory-pressure, and that a new PromiseWorker can then be generated on + * demand. + */ +add_task(async function test_memory_pressure() { + AboutHomeStartupCacheChild.init(); + + let worker = AboutHomeStartupCacheChild.getOrCreateWorker(); + Assert.ok(worker, "Should have been able to get the worker."); + + Assert.equal( + worker, + AboutHomeStartupCacheChild.getOrCreateWorker(), + "The worker is cached and re-usable." + ); + + Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize"); + + let newWorker = AboutHomeStartupCacheChild.getOrCreateWorker(); + Assert.notEqual(worker, newWorker, "Old worker should have been replaced."); + + AboutHomeStartupCacheChild.uninit(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js new file mode 100644 index 0000000000..d1f75b78bf --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutHomeStartupCacheWorker.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that the about:home startup cache worker + * script can correctly convert a state object from the Activity + * Stream Redux store into an HTML document and script. + */ + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { DiscoveryStreamFeed } = ChromeUtils.importESModule( + "resource://activity-stream/lib/DiscoveryStreamFeed.sys.mjs" +); + +SearchTestUtils.init(this); +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); +const { PREFS_CONFIG } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStream.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", +}); + +const CACHE_WORKER_URL = "resource://activity-stream/lib/cache.worker.js"; +const NEWTAB_RENDER_URL = + "resource://activity-stream/data/content/newtab-render.js"; + +/** + * In order to make this test less brittle, much of Activity Stream is + * initialized here in order to generate a state object at runtime, rather + * than hard-coding one in. This requires quite a bit of machinery in order + * to work properly. Specifically, we need to launch an HTTP server to serve + * a dynamic layout, and then have that layout point to a local feed rather + * than one from the Pocket CDN. + */ +add_setup(async function () { + do_get_profile(); + // The SearchService is also needed in order to construct the initial state, + // which means that the AddonManager needs to be available. + await AddonTestUtils.promiseStartupManager(); + + // The example.com domain will be used to host the dynamic layout JSON and + // the top stories JSON. + let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + server.registerDirectory("/", do_get_cwd()); + + // Top Stories are disabled by default in our testing profiles. + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.section.topstories", + true + ); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.system.topstories", + true + ); + + let defaultDSConfig = JSON.parse( + PREFS_CONFIG.get("discoverystream.config").getValue({ + geo: "US", + locale: "en-US", + }) + ); + + const sandbox = sinon.createSandbox(); + sandbox + .stub(DiscoveryStreamFeed.prototype, "generateFeedUrl") + .returns("http://example.com/topstories.json"); + + // Configure Activity Stream to query for the layout JSON file that points + // at the local top stories feed. + Services.prefs.setCharPref( + "browser.newtabpage.activity-stream.discoverystream.config", + JSON.stringify(defaultDSConfig) + ); + + // We need to allow example.com as a place to get both the layout and the + // top stories from. + Services.prefs.setCharPref( + "browser.newtabpage.activity-stream.discoverystream.endpoints", + `http://example.com` + ); + + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.telemetry.structuredIngestion", + false + ); + + // We need a default search engine set up for rendering the search input. + await SearchTestUtils.installSearchExtension( + { + name: "Test engine", + keyword: "@testengine", + search_url_get_params: "s={searchTerms}", + }, + { setAsDefault: true } + ); + + // Initialize Activity Stream, and pretend that a new window has been loaded + // to kick off initializing all of the feeds. + AboutNewTab.init(); + AboutNewTab.onBrowserReady(); + + // Much of Activity Stream initializes asynchronously. This is the easiest way + // I could find to ensure that enough of the feeds had initialized to produce + // a meaningful cached document. + await TestUtils.waitForCondition(() => { + let feed = AboutNewTab.activityStream.store.feeds.get( + "feeds.discoverystreamfeed" + ); + return feed?.loaded; + }); +}); + +/** + * Gets the Activity Stream Redux state from Activity Stream and sends it + * into an instance of the cache worker to ensure that the resulting markup + * and script makes sense. + */ +add_task(async function test_cache_worker() { + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + let state = AboutNewTab.activityStream.store.getState(); + + let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL); + let { page, script } = await cacheWorker.post("construct", [state]); + ok(!!page.length, "Got page content"); + ok(!!script.length, "Got script content"); + + // The template strings should have been replaced. + equal( + page.indexOf("{{ MARKUP }}"), + -1, + "Page template should have {{ MARKUP }} replaced" + ); + equal( + page.indexOf("{{ CACHE_TIME }}"), + -1, + "Page template should have {{ CACHE_TIME }} replaced" + ); + equal( + script.indexOf("{{ STATE }}"), + -1, + "Script template should have {{ STATE }} replaced" + ); + + // Now let's make sure that the generated script makes sense. We'll + // evaluate it in a sandbox to make sure broken JS doesn't break the + // test. + let sandbox = Cu.Sandbox(Cu.getGlobalForObject({})); + let passedState = null; + + // window.NewtabRenderUtils.renderCache is the exposed API from + // activity-stream.jsx that the script is expected to call to hydrate + // the pre-rendered markup. We'll implement that, and use that to ensure + // that the passed in state object matches the state we sent into the + // worker. + sandbox.window = { + NewtabRenderUtils: { + renderCache(aState) { + passedState = aState; + }, + }, + }; + Cu.evalInSandbox(script, sandbox); + + // The NEWTAB_RENDER_URL script is what ultimately causes the state + // to be passed into the renderCache function. + Services.scriptloader.loadSubScript(NEWTAB_RENDER_URL, sandbox); + + equal( + sandbox.window.__FROM_STARTUP_CACHE__, + true, + "Should have set __FROM_STARTUP_CACHE__ to true" + ); + + // The worker is expected to modify the state slightly before running + // it through ReactDOMServer by setting App.isForStartupCache to true. + // This allows React components to change their behaviour if the cache + // is being generated. + state.App.isForStartupCache = true; + + // Some of the properties on the state might have values set to undefined. + // There is no way to express a named undefined property on an object in + // JSON, so we filter those out by stringifying and re-parsing. + state = JSON.parse(JSON.stringify(state)); + + Assert.deepEqual( + passedState, + state, + "Should have called renderCache with the expected state" + ); + + // Now let's do a quick smoke-test on the markup to ensure that the + // one Top Story from topstories.json is there. + let parser = new DOMParser(); + let doc = parser.parseFromString(page, "text/html"); + let root = doc.getElementById("root"); + ok(root.childElementCount, "There are children on the root node"); + + // There should be the 1 top story, and 20 placeholders. + equal( + Array.from(root.querySelectorAll(".ds-card")).length, + 21, + "There are 21 DSCards" + ); + let cardHostname = doc.querySelector( + "[data-section-id='topstories'] .source" + ).innerText; + equal(cardHostname, "bbc.com", "Card hostname is bbc.com"); + + let placeholders = doc.querySelectorAll(".ds-card.placeholder"); + equal(placeholders.length, 20, "There should be 20 placeholders"); +}); + +/** + * Tests that if the cache-worker construct method throws an exception + * that the construct Promise still resolves. Passing a null state should + * be enough to get it to throw. + */ +add_task(async function test_cache_worker_exception() { + let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL); + let { page, script } = await cacheWorker.post("construct", [null]); + equal(page, null, "Should have gotten a null page nsIInputStream"); + equal(script, null, "Should have gotten a null script nsIInputStream"); +}); diff --git a/browser/components/newtab/test/xpcshell/test_AboutNewTab.js b/browser/components/newtab/test/xpcshell/test_AboutNewTab.js new file mode 100644 index 0000000000..1eb8081d25 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutNewTab.js @@ -0,0 +1,363 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +/** + * This file tests both the AboutNewTab and nsIAboutNewTabService + * for its default URL values, as well as its behaviour when overriding + * the default URL values. + */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "aboutNewTabService", + "@mozilla.org/browser/aboutnewtab-service;1", + "nsIAboutNewTabService" +); + +AboutNewTab.init(); + +const IS_RELEASE_OR_BETA = AppConstants.RELEASE_OR_BETA; + +const DOWNLOADS_URL = + "chrome://browser/content/downloads/contentAreaDownloadsView.xhtml"; +const SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF = + "browser.tabs.remote.separatePrivilegedContentProcess"; +const ACTIVITY_STREAM_DEBUG_PREF = "browser.newtabpage.activity-stream.debug"; +const SIMPLIFIED_WELCOME_ENABLED_PREF = "browser.aboutwelcome.enabled"; + +function cleanup() { + Services.prefs.clearUserPref(SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF); + Services.prefs.clearUserPref(ACTIVITY_STREAM_DEBUG_PREF); + Services.prefs.clearUserPref(SIMPLIFIED_WELCOME_ENABLED_PREF); + AboutNewTab.resetNewTabURL(); +} + +registerCleanupFunction(cleanup); + +let ACTIVITY_STREAM_URL; +let ACTIVITY_STREAM_DEBUG_URL; + +function setExpectedUrlsWithScripts() { + ACTIVITY_STREAM_URL = + "resource://activity-stream/prerendered/activity-stream.html"; + ACTIVITY_STREAM_DEBUG_URL = + "resource://activity-stream/prerendered/activity-stream-debug.html"; +} + +function setExpectedUrlsWithoutScripts() { + ACTIVITY_STREAM_URL = + "resource://activity-stream/prerendered/activity-stream-noscripts.html"; + + // Debug urls are the same as non-debug because debug scripts load dynamically + ACTIVITY_STREAM_DEBUG_URL = ACTIVITY_STREAM_URL; +} + +function nextChangeNotificationPromise(aNewURL, testMessage) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + Services.obs.removeObserver(observer, aTopic); + Assert.equal(aData, aNewURL, testMessage); + resolve(); + }, "newtab-url-changed"); + }); +} + +function setPrivilegedContentProcessPref(usePrivilegedContentProcess) { + if ( + usePrivilegedContentProcess === AboutNewTab.privilegedAboutProcessEnabled + ) { + return Promise.resolve(); + } + + let notificationPromise = nextChangeNotificationPromise("about:newtab"); + + Services.prefs.setBoolPref( + SEPARATE_PRIVILEGED_CONTENT_PROCESS_PREF, + usePrivilegedContentProcess + ); + return notificationPromise; +} + +// Default expected URLs to files with scripts in them. +setExpectedUrlsWithScripts(); + +function addTestsWithPrivilegedContentProcessPref(test) { + add_task(async () => { + await setPrivilegedContentProcessPref(true); + setExpectedUrlsWithoutScripts(); + await test(); + }); + add_task(async () => { + await setPrivilegedContentProcessPref(false); + setExpectedUrlsWithScripts(); + await test(); + }); +} + +function setBoolPrefAndWaitForChange(pref, value, testMessage) { + return new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + Services.obs.removeObserver(observer, aTopic); + Assert.equal(aData, AboutNewTab.newTabURL, testMessage); + resolve(); + }, "newtab-url-changed"); + + Services.prefs.setBoolPref(pref, value); + }); +} + +add_task(async function test_as_initial_values() { + Assert.ok( + AboutNewTab.activityStreamEnabled, + ".activityStreamEnabled should be set to the correct initial value" + ); + // This pref isn't defined on release or beta, so we fall back to false + Assert.equal( + AboutNewTab.activityStreamDebug, + Services.prefs.getBoolPref(ACTIVITY_STREAM_DEBUG_PREF, false), + ".activityStreamDebug should be set to the correct initial value" + ); +}); + +/** + * Test the overriding of the default URL + */ +add_task(async function test_override_activity_stream_disabled() { + let notificationPromise; + + Assert.ok( + !AboutNewTab.newTabURLOverridden, + "Newtab URL should not be overridden" + ); + const ORIGINAL_URL = aboutNewTabService.defaultURL; + + // override with some remote URL + let url = "http://example.com/"; + notificationPromise = nextChangeNotificationPromise(url); + AboutNewTab.newTabURL = url; + await notificationPromise; + Assert.ok(AboutNewTab.newTabURLOverridden, "Newtab URL should be overridden"); + Assert.ok( + !AboutNewTab.activityStreamEnabled, + "Newtab activity stream should not be enabled" + ); + Assert.equal( + AboutNewTab.newTabURL, + url, + "Newtab URL should be the custom URL" + ); + Assert.equal( + aboutNewTabService.defaultURL, + ORIGINAL_URL, + "AboutNewTabService defaultURL is unchanged" + ); + + // test reset with activity stream disabled + notificationPromise = nextChangeNotificationPromise("about:newtab"); + AboutNewTab.resetNewTabURL(); + await notificationPromise; + Assert.ok( + !AboutNewTab.newTabURLOverridden, + "Newtab URL should not be overridden" + ); + Assert.equal( + AboutNewTab.newTabURL, + "about:newtab", + "Newtab URL should be the default" + ); + + // test override to a chrome URL + notificationPromise = nextChangeNotificationPromise(DOWNLOADS_URL); + AboutNewTab.newTabURL = DOWNLOADS_URL; + await notificationPromise; + Assert.ok(AboutNewTab.newTabURLOverridden, "Newtab URL should be overridden"); + Assert.equal( + AboutNewTab.newTabURL, + DOWNLOADS_URL, + "Newtab URL should be the custom URL" + ); + + cleanup(); +}); + +addTestsWithPrivilegedContentProcessPref( + async function test_override_activity_stream_enabled() { + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_URL, + "Newtab URL should be the default activity stream URL" + ); + Assert.ok( + !AboutNewTab.newTabURLOverridden, + "Newtab URL should not be overridden" + ); + Assert.ok( + AboutNewTab.activityStreamEnabled, + "Activity Stream should be enabled" + ); + + // change to a chrome URL while activity stream is enabled + let notificationPromise = nextChangeNotificationPromise(DOWNLOADS_URL); + AboutNewTab.newTabURL = DOWNLOADS_URL; + await notificationPromise; + Assert.equal( + AboutNewTab.newTabURL, + DOWNLOADS_URL, + "Newtab URL set to chrome url" + ); + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_URL, + "Newtab URL defaultURL still set to the default activity stream URL" + ); + Assert.ok( + AboutNewTab.newTabURLOverridden, + "Newtab URL should be overridden" + ); + Assert.ok( + !AboutNewTab.activityStreamEnabled, + "Activity Stream should not be enabled" + ); + + cleanup(); + } +); + +addTestsWithPrivilegedContentProcessPref(async function test_default_url() { + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_URL, + "Newtab defaultURL initially set to AS url" + ); + + // Only debug variants aren't available on release/beta + if (!IS_RELEASE_OR_BETA) { + await setBoolPrefAndWaitForChange( + ACTIVITY_STREAM_DEBUG_PREF, + true, + "A notification occurs after changing the debug pref to true" + ); + Assert.equal( + AboutNewTab.activityStreamDebug, + true, + "the .activityStreamDebug property is set to true" + ); + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_DEBUG_URL, + "Newtab defaultURL set to debug AS url after the pref has been changed" + ); + await setBoolPrefAndWaitForChange( + ACTIVITY_STREAM_DEBUG_PREF, + false, + "A notification occurs after changing the debug pref to false" + ); + } else { + Services.prefs.setBoolPref(ACTIVITY_STREAM_DEBUG_PREF, true); + + Assert.equal( + AboutNewTab.activityStreamDebug, + false, + "the .activityStreamDebug property is remains false" + ); + } + + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_URL, + "Newtab defaultURL set to un-prerendered AS if prerender is false and debug is false" + ); + + cleanup(); +}); + +addTestsWithPrivilegedContentProcessPref(async function test_welcome_url() { + // Disable about:welcome to load newtab + Services.prefs.setBoolPref(SIMPLIFIED_WELCOME_ENABLED_PREF, false); + Assert.equal( + aboutNewTabService.welcomeURL, + ACTIVITY_STREAM_URL, + "Newtab welcomeURL set to un-prerendered AS when debug disabled." + ); + Assert.equal( + aboutNewTabService.welcomeURL, + aboutNewTabService.defaultURL, + "Newtab welcomeURL is equal to defaultURL when prerendering disabled and debug disabled." + ); + + // Only debug variants aren't available on release/beta + if (!IS_RELEASE_OR_BETA) { + await setBoolPrefAndWaitForChange( + ACTIVITY_STREAM_DEBUG_PREF, + true, + "A notification occurs after changing the debug pref to true." + ); + Assert.equal( + aboutNewTabService.welcomeURL, + ACTIVITY_STREAM_DEBUG_URL, + "Newtab welcomeURL set to un-prerendered debug AS when debug enabled" + ); + } + + cleanup(); +}); + +/** + * Tests response to updates to prefs + */ +addTestsWithPrivilegedContentProcessPref(async function test_updates() { + // Simulates a "cold-boot" situation, with some pref already set before testing a series + // of changes. + AboutNewTab.resetNewTabURL(); // need to set manually because pref notifs are off + let notificationPromise; + + // test update fires on override and reset + let testURL = "https://example.com/"; + notificationPromise = nextChangeNotificationPromise( + testURL, + "a notification occurs on override" + ); + AboutNewTab.newTabURL = testURL; + await notificationPromise; + + // from overridden to default + notificationPromise = nextChangeNotificationPromise( + "about:newtab", + "a notification occurs on reset" + ); + AboutNewTab.resetNewTabURL(); + Assert.ok( + AboutNewTab.activityStreamEnabled, + "Activity Stream should be enabled" + ); + Assert.equal( + aboutNewTabService.defaultURL, + ACTIVITY_STREAM_URL, + "Default URL should be the activity stream page" + ); + await notificationPromise; + + // reset twice, only one notification for default URL + notificationPromise = nextChangeNotificationPromise( + "about:newtab", + "reset occurs" + ); + AboutNewTab.resetNewTabURL(); + await notificationPromise; + + cleanup(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js new file mode 100644 index 0000000000..3d83f473d5 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeAttribution.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { AboutWelcomeDefaults } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeDefaults.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { AttributionCode } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { AddonRepository } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonRepository.sys.mjs" +); + +const TEST_ATTRIBUTION_DATA = { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: "rta:iridium%40particlecore.github.io", +}; + +add_task(async function test_handleAddonInfoNotFound() { + let sandbox = sinon.createSandbox(); + const stub = sandbox.stub(AttributionCode, "getAttrDataAsync").resolves(null); + let result = await AboutWelcomeDefaults.getAttributionContent(); + equal(stub.callCount, 1, "Call was made"); + equal(result, null, "No data is returned"); + + sandbox.restore(); +}); + +add_task(async function test_UAAttribution() { + let sandbox = sinon.createSandbox(); + const stub = sandbox + .stub(AttributionCode, "getAttrDataAsync") + .resolves({ ua: "test" }); + let result = await AboutWelcomeDefaults.getAttributionContent(); + equal(stub.callCount, 1, "Call was made"); + equal(result.template, undefined, "Template was not returned"); + equal(result.ua, "test", "UA was returned"); + + sandbox.restore(); +}); + +add_task(async function test_formatAttributionData() { + let sandbox = sinon.createSandbox(); + const TEST_ADDON_INFO = { + sourceURI: { scheme: "https", spec: "https://test.xpi" }, + name: "Test Add-on", + icons: { 64: "http://test.svg" }, + }; + sandbox + .stub(AttributionCode, "getAttrDataAsync") + .resolves(TEST_ATTRIBUTION_DATA); + sandbox.stub(AddonRepository, "getAddonsByIDs").resolves([TEST_ADDON_INFO]); + let result = await AboutWelcomeDefaults.getAttributionContent( + TEST_ATTRIBUTION_DATA + ); + equal(AddonRepository.getAddonsByIDs.callCount, 1, "Retrieve addon content"); + equal(result.template, "return_to_amo", "RTAMO template returned"); + equal(result.name, TEST_ADDON_INFO.name, "AddonInfo returned"); + + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js new file mode 100644 index 0000000000..b8339fb39f --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); +const { AttributionCode } = ChromeUtils.importESModule( + "resource:///modules/AttributionCode.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const TELEMETRY_PREF = "browser.newtabpage.activity-stream.telemetry"; + +add_setup(function setup() { + do_get_profile(); + Services.fog.initializeFOG(); +}); + +add_task(function test_enabled() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(TELEMETRY_PREF); + }); + Services.prefs.setBoolPref(TELEMETRY_PREF, true); + + const AWTelemetry = new AboutWelcomeTelemetry(); + + equal(AWTelemetry.telemetryEnabled, true, "Telemetry should be on"); + + Services.prefs.setBoolPref(TELEMETRY_PREF, false); + + equal(AWTelemetry.telemetryEnabled, false, "Telemetry should be off"); +}); + +add_task(async function test_pingPayload() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(TELEMETRY_PREF); + }); + Services.prefs.setBoolPref(TELEMETRY_PREF, true); + const AWTelemetry = new AboutWelcomeTelemetry(); + sinon.stub(AWTelemetry, "_createPing").resolves({ event: "MOCHITEST" }); + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal(Glean.messagingSystem.event.testGetValue(), "MOCHITEST"); + }); + await AWTelemetry.sendTelemetry(); + + ok(pingSubmitted, "Glean ping was submitted"); +}); + +add_task(function test_mayAttachAttribution() { + const sandbox = sinon.createSandbox(); + const AWTelemetry = new AboutWelcomeTelemetry(); + + sandbox.stub(AttributionCode, "getCachedAttributionData").returns(null); + + let ping = AWTelemetry._maybeAttachAttribution({}); + + equal(ping.attribution, undefined, "Should not set attribution if it's null"); + + sandbox.restore(); + sandbox.stub(AttributionCode, "getCachedAttributionData").returns({}); + ping = AWTelemetry._maybeAttachAttribution({}); + + equal( + ping.attribution, + undefined, + "Should not set attribution if it's empty" + ); + + const attr = { + source: "google.com", + medium: "referral", + campaign: "Firefox-Brand-US-Chrome", + content: "(not set)", + experiment: "(not set)", + variation: "(not set)", + ua: "chrome", + }; + sandbox.restore(); + sandbox.stub(AttributionCode, "getCachedAttributionData").returns(attr); + ping = AWTelemetry._maybeAttachAttribution({}); + + equal(ping.attribution, attr, "Should set attribution if it presents"); +}); diff --git a/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js new file mode 100644 index 0000000000..5191f05d04 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_AboutWelcomeTelemetry_glean.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { AboutWelcomeTelemetry } = ChromeUtils.importESModule( + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs" +); +const TELEMETRY_PREF = "browser.newtabpage.activity-stream.telemetry"; + +add_setup(function setup() { + do_get_profile(); + Services.fog.initializeFOG(); +}); + +// We recognize two kinds of unexpected data that might reach +// `submitGleanPingForPing`: unknown keys, and keys with unexpectedly-complex +// data (ie, non-scalar). +// We report the keys in special metrics to aid in system health monitoring. +add_task(function test_weird_data() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(TELEMETRY_PREF); + }); + Services.prefs.setBoolPref(TELEMETRY_PREF, true); + + const AWTelemetry = new AboutWelcomeTelemetry(); + + const unknownKey = "some_unknown_key"; + const camelUnknownKey = AWTelemetry._snakeToCamelCase(unknownKey); + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.unknownKeys[camelUnknownKey].testGetValue(), + 1, + "caught the unknown key" + ); + // TODO(bug 1600008): Also check the for-testing overall count. + Assert.equal(Glean.messagingSystem.unknownKeyCount.testGetValue(), 1); + }); + AWTelemetry.submitGleanPingForPing({ + [unknownKey]: "value doesn't matter", + }); + + Assert.ok(pingSubmitted, "Ping with unknown keys was submitted"); + + const invalidNestedDataKey = "event"; + pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.invalidNestedData[ + invalidNestedDataKey + ].testGetValue("messaging-system"), + 1, + "caught the invalid nested data" + ); + }); + AWTelemetry.submitGleanPingForPing({ + [invalidNestedDataKey]: { this_should: "not be", complex: "data" }, + }); + + Assert.ok(pingSubmitted, "Ping with invalid nested data submitted"); +}); + +// `event_context` is weird. It's an object, but it might have been stringified +// before being provided for recording. +add_task(function test_event_context() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(TELEMETRY_PREF); + }); + Services.prefs.setBoolPref(TELEMETRY_PREF, true); + + const AWTelemetry = new AboutWelcomeTelemetry(); + + const eventContext = { + reason: "reason", + page: "page", + source: "source", + something_else: "not specifically handled", + screen_family: "family", + screen_id: "screen_id", + screen_index: 0, + screen_initlals: "screen_initials", + }; + const stringifiedEC = JSON.stringify(eventContext); + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.eventReason.testGetValue(), + eventContext.reason, + "event_context.reason also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventPage.testGetValue(), + eventContext.page, + "event_context.page also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventSource.testGetValue(), + eventContext.source, + "event_context.source also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenFamily.testGetValue(), + eventContext.screen_family, + "event_context.screen_family also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenId.testGetValue(), + eventContext.screen_id, + "event_context.screen_id also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenIndex.testGetValue(), + eventContext.screen_index, + "event_context.screen_index also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenInitials.testGetValue(), + eventContext.screen_initials, + "event_context.screen_initials also in own metric." + ); + + Assert.equal( + Glean.messagingSystem.eventContext.testGetValue(), + stringifiedEC, + "whole event_context added as text." + ); + }); + AWTelemetry.submitGleanPingForPing({ + event_context: eventContext, + }); + Assert.ok(pingSubmitted, "Ping with object event_context submitted"); + + pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.eventReason.testGetValue(), + eventContext.reason, + "event_context.reason also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventPage.testGetValue(), + eventContext.page, + "event_context.page also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventSource.testGetValue(), + eventContext.source, + "event_context.source also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenFamily.testGetValue(), + eventContext.screen_family, + "event_context.screen_family also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenId.testGetValue(), + eventContext.screen_id, + "event_context.screen_id also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenIndex.testGetValue(), + eventContext.screen_index, + "event_context.screen_index also in own metric." + ); + Assert.equal( + Glean.messagingSystem.eventScreenInitials.testGetValue(), + eventContext.screen_initials, + "event_context.screen_initials also in own metric." + ); + + Assert.equal( + Glean.messagingSystem.eventContext.testGetValue(), + stringifiedEC, + "whole event_context added as text." + ); + }); + AWTelemetry.submitGleanPingForPing({ + event_context: stringifiedEC, + }); + Assert.ok(pingSubmitted, "Ping with string event_context submitted"); +}); + +// For event_context to be more useful, we want to make sure we don't error +// in cases where it doesn't make much sense, such as a plain string that +// doesnt attempt to represent a valid object. +add_task(function test_context_errors() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref(TELEMETRY_PREF); + }); + Services.prefs.setBoolPref(TELEMETRY_PREF, true); + + const AWTelemetry = new AboutWelcomeTelemetry(); + + let weird_context_ping = { + event_context: "oops, this string isn't a valid JS object!", + }; + + let pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.eventContextParseError.testGetValue(), + undefined, + "this poorly formed context shouldn't register because it was not an object!" + ); + }); + + AWTelemetry.submitGleanPingForPing(weird_context_ping); + + Assert.ok(pingSubmitted, "Ping with unknown keys was submitted"); + + weird_context_ping = { + event_context: + "{oops : {'this string isn't a valid JS object, but it sure looks like one!}}'", + }; + + pingSubmitted = false; + GleanPings.messagingSystem.testBeforeNextSubmit(() => { + pingSubmitted = true; + Assert.equal( + Glean.messagingSystem.eventContextParseError.testGetValue(), + 1, + "this poorly formed context should register because it was not an object!" + ); + }); + + AWTelemetry.submitGleanPingForPing(weird_context_ping); + + Assert.ok(pingSubmitted, "Ping with unknown keys was submitted"); +}); diff --git a/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js b/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js new file mode 100644 index 0000000000..233eb6df73 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_HighlightsFeed.js @@ -0,0 +1,1402 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + Screenshots: "resource://activity-stream/lib/Screenshots.sys.mjs", + SectionsManager: "resource://activity-stream/lib/SectionsManager.sys.mjs", + shortURL: "resource://activity-stream/lib/ShortURL.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const { + HighlightsFeed, + SYNC_BOOKMARKS_FINISHED_EVENT, + BOOKMARKS_RESTORE_SUCCESS_EVENT, + BOOKMARKS_RESTORE_FAILED_EVENT, + SECTION_ID, +} = ChromeUtils.import("resource://activity-stream/lib/HighlightsFeed.jsm"); + +const FAKE_LINKS = new Array(20) + .fill(null) + .map((v, i) => ({ url: `http://www.site${i}.com` })); +const FAKE_IMAGE = "data123"; +const FAKE_URL = "https://mozilla.org"; +const FAKE_IMAGE_URL = "https://mozilla.org/preview.jpg"; + +function getHighlightsFeedForTest(sandbox) { + let feed = new HighlightsFeed(); + feed.store = { + dispatch: sandbox.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { + values: { + "section.highlights.includePocket": false, + "section.highlights.includeDownloads": false, + }, + }, + TopSites: { + initialized: true, + rows: Array(12) + .fill(null) + .map((v, i) => ({ url: `http://www.topsite${i}.com` })), + }, + Sections: [{ id: "highlights", initialized: false }], + }, + subscribe: sandbox.stub().callsFake(cb => { + cb(); + return () => {}; + }), + }; + + sandbox + .stub(NewTabUtils.activityStreamLinks, "getHighlights") + .resolves(FAKE_LINKS); + sandbox + .stub(NewTabUtils.activityStreamLinks, "deletePocketEntry") + .resolves({}); + sandbox + .stub(NewTabUtils.activityStreamLinks, "archivePocketEntry") + .resolves({}); + sandbox + .stub(NewTabUtils.activityStreamProvider, "_processHighlights") + .callsFake(l => l.slice(0, 1)); + + return feed; +} + +async function fetchHighlightsRows(feed, options) { + let sandbox = sinon.createSandbox(); + sandbox.stub(SectionsManager, "updateSection"); + await feed.fetchHighlights(options); + let [, { rows }] = SectionsManager.updateSection.firstCall.args; + + sandbox.restore(); + return rows; +} + +function fetchImage(feed, page) { + return feed.fetchImage( + Object.assign({ __sharedCache: { updateLink() {} } }, page) + ); +} + +add_task(function test_construction() { + info("HighlightsFeed construction should work"); + let sandbox = sinon.createSandbox(); + sandbox.stub(PageThumbs, "addExpirationFilter"); + + let feed = getHighlightsFeedForTest(sandbox); + Assert.ok(feed, "Was able to create a HighlightsFeed"); + + info("HighlightsFeed construction should add a PageThumbs expiration filter"); + Assert.ok( + PageThumbs.addExpirationFilter.calledOnce, + "PageThumbs.addExpirationFilter was called once" + ); + + sandbox.restore(); +}); + +add_task(function test_init_action() { + let sandbox = sinon.createSandbox(); + + let countObservers = topic => { + return [...Services.obs.enumerateObservers(topic)].length; + }; + + const INITIAL_SYNC_BOOKMARKS_FINISHED_EVENT_COUNT = countObservers( + SYNC_BOOKMARKS_FINISHED_EVENT + ); + const INITIAL_BOOKMARKS_RESTORE_SUCCESS_EVENT_COUNT = countObservers( + BOOKMARKS_RESTORE_SUCCESS_EVENT + ); + const INITIAL_BOOKMARKS_RESTORE_FAILED_EVENT_COUNT = countObservers( + BOOKMARKS_RESTORE_FAILED_EVENT + ); + + sandbox + .stub(SectionsManager, "onceInitialized") + .callsFake(callback => callback()); + sandbox.stub(SectionsManager, "enableSection"); + + let feed = getHighlightsFeedForTest(sandbox); + sandbox.stub(feed, "fetchHighlights"); + sandbox.stub(feed.downloadsManager, "init"); + + feed.onAction({ type: at.INIT }); + + info("HighlightsFeed.onAction(INIT) should add a sync observer"); + Assert.equal( + countObservers(SYNC_BOOKMARKS_FINISHED_EVENT), + INITIAL_SYNC_BOOKMARKS_FINISHED_EVENT_COUNT + 1 + ); + Assert.equal( + countObservers(BOOKMARKS_RESTORE_SUCCESS_EVENT), + INITIAL_BOOKMARKS_RESTORE_SUCCESS_EVENT_COUNT + 1 + ); + Assert.equal( + countObservers(BOOKMARKS_RESTORE_FAILED_EVENT), + INITIAL_BOOKMARKS_RESTORE_FAILED_EVENT_COUNT + 1 + ); + + info( + "HighlightsFeed.onAction(INIT) should call SectionsManager.onceInitialized" + ); + Assert.ok( + SectionsManager.onceInitialized.calledOnce, + "SectionsManager.onceInitialized was called" + ); + + info("HighlightsFeed.onAction(INIT) should enable its section"); + Assert.ok( + SectionsManager.enableSection.calledOnce, + "SectionsManager.enableSection was called" + ); + Assert.ok(SectionsManager.enableSection.calledWith(SECTION_ID)); + + info("HighlightsFeed.onAction(INIT) should fetch highlights"); + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights was called" + ); + + info("HighlightsFeed.onAction(INIT) should initialize the DownloadsManager"); + Assert.ok( + feed.downloadsManager.init.calledOnce, + "HighlightsFeed.downloadsManager.init was called" + ); + + feed.uninit(); + // Let's make sure that uninit also removed these observers while we're here. + Assert.equal( + countObservers(SYNC_BOOKMARKS_FINISHED_EVENT), + INITIAL_SYNC_BOOKMARKS_FINISHED_EVENT_COUNT + ); + Assert.equal( + countObservers(BOOKMARKS_RESTORE_SUCCESS_EVENT), + INITIAL_BOOKMARKS_RESTORE_SUCCESS_EVENT_COUNT + ); + Assert.equal( + countObservers(BOOKMARKS_RESTORE_FAILED_EVENT), + INITIAL_BOOKMARKS_RESTORE_FAILED_EVENT_COUNT + ); + + sandbox.restore(); +}); + +add_task(async function test_observe_fetch_highlights() { + let topicDataPairs = [ + { + description: + "should fetch highlights when we are done a sync for bookmarks", + shouldFetch: true, + topic: SYNC_BOOKMARKS_FINISHED_EVENT, + data: "bookmarks", + }, + { + description: "should fetch highlights after a successful import", + shouldFetch: true, + topic: BOOKMARKS_RESTORE_SUCCESS_EVENT, + data: "html", + }, + { + description: "should fetch highlights after a failed import", + shouldFetch: true, + topic: BOOKMARKS_RESTORE_FAILED_EVENT, + data: "json", + }, + { + description: + "should not fetch highlights when we are doing a sync for something that is not bookmarks", + shouldFetch: false, + topic: SYNC_BOOKMARKS_FINISHED_EVENT, + data: "tabs", + }, + { + description: "should not fetch highlights after a successful import", + shouldFetch: false, + topic: "someotherevent", + data: "bookmarks", + }, + ]; + + for (let topicDataPair of topicDataPairs) { + info(`HighlightsFeed.observe ${topicDataPair.description}`); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + sandbox.stub(feed, "fetchHighlights"); + feed.observe(null, topicDataPair.topic, topicDataPair.data); + + if (topicDataPair.shouldFetch) { + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights was called" + ); + Assert.ok(feed.fetchHighlights.calledWith({ broadcast: true })); + } else { + Assert.ok( + feed.fetchHighlights.notCalled, + "HighlightsFeed.fetchHighlights was not called" + ); + } + + sandbox.restore(); + } +}); + +add_task(async function test_filterForThumbnailExpiration_calls() { + info( + "HighlightsFeed.filterForThumbnailExpiration should pass rows.urls " + + "to the callback provided" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + let rows = [{ url: "foo.com" }, { url: "bar.com" }]; + + feed.store.state.Sections = [{ id: "highlights", rows, initialized: true }]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + Assert.ok(stub.calledOnce, "Filter was called"); + Assert.ok(stub.calledWithExactly(rows.map(r => r.url))); + + sandbox.restore(); +}); + +add_task( + async function test_filterForThumbnailExpiration_include_preview_image_url() { + info( + "HighlightsFeed.filterForThumbnailExpiration should include " + + "preview_image_url (if present) in the callback results" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + let rows = [ + { url: "foo.com" }, + { url: "bar.com", preview_image_url: "bar.jpg" }, + ]; + + feed.store.state.Sections = [{ id: "highlights", rows, initialized: true }]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + Assert.ok(stub.calledOnce, "Filter was called"); + Assert.ok(stub.calledWithExactly(["foo.com", "bar.com", "bar.jpg"])); + + sandbox.restore(); + } +); + +add_task(async function test_filterForThumbnailExpiration_not_initialized() { + info( + "HighlightsFeed.filterForThumbnailExpiration should pass an empty " + + "array if not initialized" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + let rows = [{ url: "foo.com" }, { url: "bar.com" }]; + + feed.store.state.Sections = [{ rows, initialized: false }]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + Assert.ok(stub.calledOnce, "Filter was called"); + Assert.ok(stub.calledWithExactly([])); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_TopSites_not_initialized() { + info( + "HighlightsFeed.fetchHighlights should return early if TopSites are not " + + "initialized" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed.linksCache, "request"); + + feed.store.state.TopSites.initialized = false; + feed.store.state.Prefs.values["feeds.topsites"] = true; + feed.store.state.Prefs.values["feeds.system.topsites"] = true; + + // Initially TopSites is uninitialised and fetchHighlights should return. + await feed.fetchHighlights(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getHighlights.notCalled, + "NewTabUtils.activityStreamLinks.getHighlights was not called" + ); + Assert.ok( + feed.linksCache.request.notCalled, + "HighlightsFeed.linksCache.request was not called" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_sections_not_initialized() { + info( + "HighlightsFeed.fetchHighlights should return early if Sections are not " + + "initialized" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed.linksCache, "request"); + + feed.store.state.TopSites.initialized = true; + feed.store.state.Prefs.values["feeds.topsites"] = true; + feed.store.state.Prefs.values["feeds.system.topsites"] = true; + feed.store.state.Sections = []; + + await feed.fetchHighlights(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getHighlights.notCalled, + "NewTabUtils.activityStreamLinks.getHighlights was not called" + ); + Assert.ok( + feed.linksCache.request.notCalled, + "HighlightsFeed.linksCache.request was not called" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_TopSites_initialized() { + info( + "HighlightsFeed.fetchHighlights should fetch Highlights if TopSites are " + + "initialised" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed.linksCache, "request"); + + // fetchHighlights should continue + feed.store.state.TopSites.initialized = true; + + await feed.fetchHighlights(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getHighlights.calledOnce, + "NewTabUtils.activityStreamLinks.getHighlights was called" + ); + Assert.ok( + feed.linksCache.request.calledOnce, + "HighlightsFeed.linksCache.request was called" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_chronological_order() { + info( + "HighlightsFeed.fetchHighlights should chronologically order highlight " + + "data types" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { + url: "https://site0.com", + type: "bookmark", + bookmarkGuid: "1234", + date_added: Date.now() - 80, + }, // 3rd newest + { + url: "https://site1.com", + type: "history", + bookmarkGuid: "1234", + date_added: Date.now() - 60, + }, // append at the end + { + url: "https://site2.com", + type: "history", + date_added: Date.now() - 160, + }, // append at the end + { + url: "https://site3.com", + type: "history", + date_added: Date.now() - 60, + }, // append at the end + { url: "https://site4.com", type: "pocket", date_added: Date.now() }, // newest highlight + { + url: "https://site5.com", + type: "pocket", + date_added: Date.now() - 100, + }, // 4th newest + { + url: "https://site6.com", + type: "bookmark", + bookmarkGuid: "1234", + date_added: Date.now() - 40, + }, // 2nd newest + ]; + let expectedChronological = [4, 6, 0, 5]; + let expectedHistory = [1, 2, 3]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + [...expectedChronological, ...expectedHistory].forEach((link, index) => { + Assert.equal( + highlights[index].url, + links[link].url, + `highlight[${index}] should be link[${link}]` + ); + }); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_TopSites_not_enabled() { + info( + "HighlightsFeed.fetchHighlights should fetch Highlights if TopSites " + + "are not enabled" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed.linksCache, "request"); + + feed.store.state.Prefs.values["feeds.system.topsites"] = false; + + await feed.fetchHighlights(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getHighlights.calledOnce, + "NewTabUtils.activityStreamLinks.getHighlights was called" + ); + Assert.ok( + feed.linksCache.request.calledOnce, + "HighlightsFeed.linksCache.request was called" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_TopSites_not_shown() { + info( + "HighlightsFeed.fetchHighlights should fetch Highlights if TopSites " + + "are not shown on NTP" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed.linksCache, "request"); + + feed.store.state.Prefs.values["feeds.topsites"] = false; + + await feed.fetchHighlights(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getHighlights.calledOnce, + "NewTabUtils.activityStreamLinks.getHighlights was called" + ); + Assert.ok( + feed.linksCache.request.calledOnce, + "HighlightsFeed.linksCache.request was called" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_add_hostname_hasImage() { + info( + "HighlightsFeed.fetchHighlights should add shortURL hostname and hasImage to each link" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [{ url: "https://mozilla.org" }]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights[0].hostname, shortURL(links[0])); + Assert.equal(highlights[0].hasImage, true); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_add_existing_image() { + info( + "HighlightsFeed.fetchHighlights should add an existing image if it " + + "exists to the link without calling fetchImage" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [{ url: "https://mozilla.org", image: FAKE_IMAGE }]; + sandbox.spy(feed, "fetchImage"); + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights[0].image, FAKE_IMAGE); + Assert.ok(feed.fetchImage.notCalled, "HighlightsFeed.fetchImage not called"); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_correct_args() { + info( + "HighlightsFeed.fetchHighlights should call fetchImage with the correct " + + "arguments for new links" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { + url: "https://mozilla.org", + preview_image_url: "https://mozilla.org/preview.jog", + }, + ]; + sandbox.spy(feed, "fetchImage"); + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + await fetchHighlightsRows(feed); + + Assert.ok(feed.fetchImage.calledOnce, "HighlightsFeed.fetchImage called"); + + let [arg] = feed.fetchImage.firstCall.args; + Assert.equal(arg.url, links[0].url); + Assert.equal(arg.preview_image_url, links[0].preview_image_url); + + sandbox.restore(); +}); + +add_task( + async function test_fetchHighlights_not_include_links_already_in_TopSites() { + info( + "HighlightsFeed.fetchHighlights should not include any links already in " + + "Top Sites" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { url: "https://mozilla.org" }, + { url: "http://www.topsite0.com" }, + { url: "http://www.topsite1.com" }, + { url: "http://www.topsite2.com" }, + ]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 1); + Assert.equal(highlights[0].url, links[0].url); + + sandbox.restore(); + } +); + +add_task( + async function test_fetchHighlights_not_include_history_already_in_TopSites() { + info( + "HighlightsFeed.fetchHighlights should include bookmark but not " + + "history already in Top Sites" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { url: "http://www.topsite0.com", type: "bookmark" }, + { url: "http://www.topsite1.com", type: "history" }, + ]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 1); + Assert.equal(highlights[0].url, links[0].url); + + sandbox.restore(); + } +); + +add_task( + async function test_fetchHighlights_not_include_history_same_hostname_as_bookmark() { + info( + "HighlightsFeed.fetchHighlights should not include history of same " + + "hostname as a bookmark" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/history", type: "history" }, + ]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 1); + Assert.equal(highlights[0].url, links[0].url); + + sandbox.restore(); + } +); + +add_task(async function test_fetchHighlights_take_first_history_of_hostname() { + info( + "HighlightsFeed.fetchHighlights should take the first history of a hostname" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { url: "https://site.com/first", type: "history" }, + { url: "https://site.com/second", type: "history" }, + { url: "https://other", type: "history" }, + ]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 2); + Assert.equal(highlights[0].url, links[0].url); + Assert.equal(highlights[1].url, links[2].url); + + sandbox.restore(); +}); + +add_task( + async function test_fetchHighlights_take_bookmark_pocket_download_of_same_hostname() { + info( + "HighlightsFeed.fetchHighlights should take a bookmark, a pocket, and " + + "downloaded item of the same hostname" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + { url: "https://site.com/download", type: "download" }, + ]; + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 3); + Assert.equal(highlights[0].url, links[0].url); + Assert.equal(highlights[1].url, links[1].url); + Assert.equal(highlights[2].url, links[2].url); + + sandbox.restore(); + } +); + +add_task(async function test_fetchHighlights_include_pocket_items() { + info( + "HighlightsFeed.fetchHighlights should includePocket pocket items when " + + "pref is true" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includePocket"] = true; + sandbox.spy(feed.linksCache, "request"); + + await fetchHighlightsRows(feed); + + Assert.ok( + feed.linksCache.request.calledOnce, + "HighlightsFeed.linksCache.request called" + ); + Assert.ok( + !feed.linksCache.request.firstCall.args[0].excludePocket, + "Should not be excluding Pocket items" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_do_not_include_pocket_items() { + info( + "HighlightsFeed.fetchHighlights should not includePocket pocket items " + + "when pref is false" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includePocket"] = false; + sandbox.spy(feed.linksCache, "request"); + + await fetchHighlightsRows(feed); + + Assert.ok( + feed.linksCache.request.calledOnce, + "HighlightsFeed.linksCache.request called" + ); + Assert.ok( + feed.linksCache.request.firstCall.args[0].excludePocket, + "Should be excluding Pocket items" + ); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_do_not_include_downloads() { + info( + "HighlightsFeed.fetchHighlights should not include downloads when " + + "includeDownloads pref is false" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includeDownloads"] = false; + feed.downloadsManager.getDownloads = () => [ + { url: "https://site1.com/download" }, + { url: "https://site2.com/download" }, + ]; + + let links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + ]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 2); + Assert.equal(highlights[0].url, links[0].url); + Assert.equal(highlights[1].url, links[1].url); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_include_downloads() { + info( + "HighlightsFeed.fetchHighlights should include downloads when " + + "includeDownloads pref is true" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includeDownloads"] = true; + feed.downloadsManager.getDownloads = () => [ + { url: "https://site.com/download" }, + ]; + + let links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + ]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 3); + Assert.equal(highlights[0].url, links[0].url); + Assert.equal(highlights[1].url, links[1].url); + Assert.equal(highlights[2].url, "https://site.com/download"); + Assert.equal(highlights[2].type, "download"); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_take_one_download() { + info("HighlightsFeed.fetchHighlights should only take 1 download"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includeDownloads"] = true; + feed.downloadsManager.getDownloads = () => [ + { url: "https://site1.com/download" }, + { url: "https://site2.com/download" }, + ]; + + let links = [{ url: "https://site.com/bookmark", type: "bookmark" }]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 2); + Assert.equal(highlights[0].url, links[0].url); + Assert.equal(highlights[1].url, "https://site1.com/download"); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_chronological_sort() { + info( + "HighlightsFeed.fetchHighlights should sort bookmarks, pocket, " + + "and downloads chronologically" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includeDownloads"] = true; + feed.downloadsManager.getDownloads = () => [ + { + url: "https://site1.com/download", + type: "download", + date_added: Date.now(), + }, + ]; + + let links = [ + { + url: "https://site.com/bookmark", + type: "bookmark", + date_added: Date.now() - 10000, + }, + { + url: "https://site2.com/pocket", + type: "pocket", + date_added: Date.now() - 5000, + }, + { + url: "https://site3.com/visited", + type: "history", + date_added: Date.now(), + }, + ]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights.length, 4); + Assert.equal(highlights[0].url, "https://site1.com/download"); + Assert.equal(highlights[1].url, links[1].url); + Assert.equal(highlights[2].url, links[0].url); + Assert.equal(highlights[3].url, links[2].url); // history item goes last + + sandbox.restore(); +}); + +add_task( + async function test_fetchHighlights_set_type_to_bookmark_on_bookmarkGuid() { + info( + "HighlightsFeed.fetchHighlights should set type to bookmark if there " + + "is a bookmarkGuid" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values["section.highlights.includeBookmarks"] = true; + feed.downloadsManager.getDownloads = () => [ + { + url: "https://site1.com/download", + type: "download", + date_added: Date.now(), + }, + ]; + + let links = [ + { + url: "https://mozilla.org", + type: "history", + bookmarkGuid: "1234567890", + }, + ]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights[0].type, "bookmark"); + + sandbox.restore(); + } +); + +add_task( + async function test_fetchHighlights_keep_history_type_on_bookmarkGuid() { + info( + "HighlightsFeed.fetchHighlights should keep history type if there is a " + + "bookmarkGuid but don't include bookmarks" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.store.state.Prefs.values[ + "section.highlights.includeBookmarks" + ] = false; + + let links = [ + { + url: "https://mozilla.org", + type: "history", + bookmarkGuid: "1234567890", + }, + ]; + + NewTabUtils.activityStreamLinks.getHighlights.resolves(links); + + let highlights = await fetchHighlightsRows(feed); + + Assert.equal(highlights[0].type, "history"); + + sandbox.restore(); + } +); + +add_task(async function test_fetchHighlights_filter_adult() { + info("HighlightsFeed.fetchHighlights should filter out adult pages"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.stub(FilterAdult, "filter").returns([]); + let highlights = await fetchHighlightsRows(feed); + + Assert.ok(FilterAdult.filter.calledOnce, "FilterAdult.filter called"); + Assert.equal(highlights.length, 0); + + sandbox.restore(); +}); + +add_task(async function test_fetchHighlights_no_expose_internal_link_props() { + info( + "HighlightsFeed.fetchHighlights should not expose internal link properties" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + let highlights = await fetchHighlightsRows(feed); + let internal = Object.keys(highlights[0]).filter(key => key.startsWith("__")); + + Assert.equal(internal.join(""), ""); + + sandbox.restore(); +}); + +add_task( + async function test_fetchHighlights_broadcast_when_feed_not_initialized() { + info( + "HighlightsFeed.fetchHighlights should broadcast if feed is not initialized" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + NewTabUtils.activityStreamLinks.getHighlights.resolves([]); + sandbox.stub(SectionsManager, "updateSection"); + await feed.fetchHighlights(); + + Assert.ok( + SectionsManager.updateSection.calledOnce, + "SectionsManager.updateSection called once" + ); + Assert.ok( + SectionsManager.updateSection.calledWithExactly( + SECTION_ID, + { rows: [] }, + true, + undefined + ) + ); + sandbox.restore(); + } +); + +add_task( + async function test_fetchHighlights_broadcast_on_broadcast_in_options() { + info( + "HighlightsFeed.fetchHighlights should broadcast if options.broadcast is true" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + NewTabUtils.activityStreamLinks.getHighlights.resolves([]); + feed.store.state.Sections[0].initialized = true; + + sandbox.stub(SectionsManager, "updateSection"); + await feed.fetchHighlights({ broadcast: true }); + + Assert.ok( + SectionsManager.updateSection.calledOnce, + "SectionsManager.updateSection called once" + ); + Assert.ok( + SectionsManager.updateSection.calledWithExactly( + SECTION_ID, + { rows: [] }, + true, + undefined + ) + ); + sandbox.restore(); + } +); + +add_task(async function test_fetchHighlights_no_broadcast() { + info( + "HighlightsFeed.fetchHighlights should not broadcast if " + + "options.broadcast is false and initialized is true" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + NewTabUtils.activityStreamLinks.getHighlights.resolves([]); + feed.store.state.Sections[0].initialized = true; + + sandbox.stub(SectionsManager, "updateSection"); + await feed.fetchHighlights({ broadcast: false }); + + Assert.ok( + SectionsManager.updateSection.calledOnce, + "SectionsManager.updateSection called once" + ); + Assert.ok( + SectionsManager.updateSection.calledWithExactly( + SECTION_ID, + { rows: [] }, + false, + undefined + ) + ); + sandbox.restore(); +}); + +add_task(async function test_fetchImage_capture_if_available() { + info("HighlightsFeed.fetchImage should capture the image, if available"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.stub(Screenshots, "getScreenshotForURL"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + await fetchImage(feed, { + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }); + + Assert.ok( + Screenshots.getScreenshotForURL.calledOnce, + "Screenshots.getScreenshotForURL called once" + ); + Assert.ok(Screenshots.getScreenshotForURL.calledWith(FAKE_IMAGE_URL)); + + sandbox.restore(); +}); + +add_task(async function test_fetchImage_fallback_to_screenshot() { + info("HighlightsFeed.fetchImage should fall back to capturing a screenshot"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.stub(Screenshots, "getScreenshotForURL"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + await fetchImage(feed, { url: FAKE_URL }); + + Assert.ok( + Screenshots.getScreenshotForURL.calledOnce, + "Screenshots.getScreenshotForURL called once" + ); + Assert.ok(Screenshots.getScreenshotForURL.calledWith(FAKE_URL)); + + sandbox.restore(); +}); + +add_task(async function test_fetchImage_updateSectionCard_args() { + info( + "HighlightsFeed.fetchImage should call " + + "SectionsManager.updateSectionCard with the right arguments" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.stub(SectionsManager, "updateSectionCard"); + sandbox.stub(Screenshots, "getScreenshotForURL").resolves(FAKE_IMAGE); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + await fetchImage(feed, { + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }); + Assert.ok( + SectionsManager.updateSectionCard.calledOnce, + "SectionsManager.updateSectionCard called" + ); + Assert.ok( + SectionsManager.updateSectionCard.calledWith( + "highlights", + FAKE_URL, + { image: FAKE_IMAGE }, + true + ) + ); + sandbox.restore(); +}); + +add_task(async function test_fetchImage_no_update_card_with_image() { + info("HighlightsFeed.fetchImage should not update the card with the image"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.stub(SectionsManager, "updateSectionCard"); + sandbox.stub(Screenshots, "getScreenshotForURL").resolves(FAKE_IMAGE); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + + let card = { + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }; + await fetchImage(feed, card); + Assert.ok(!card.image, "Image not set on card"); + sandbox.restore(); +}); + +add_task(async function test_uninit_disable_section() { + info("HighlightsFeed.onAction(UNINIT) should disable its section"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.init(); + + sandbox.stub(SectionsManager, "disableSection"); + feed.onAction({ type: at.UNINIT }); + Assert.ok( + SectionsManager.disableSection.calledOnce, + "SectionsManager.disableSection called" + ); + Assert.ok(SectionsManager.disableSection.calledWith(SECTION_ID)); + sandbox.restore(); +}); + +add_task(async function test_uninit_remove_expiration_filter() { + info("HighlightsFeed.onAction(UNINIT) should remove the expiration filter"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + feed.init(); + + sandbox.stub(PageThumbs, "removeExpirationFilter"); + feed.onAction({ type: at.UNINIT }); + Assert.ok( + PageThumbs.removeExpirationFilter.calledOnce, + "PageThumbs.removeExpirationFilter called" + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_relay_to_DownloadsManager_onAction() { + info( + "HighlightsFeed.onAction should relay all actions to " + + "DownloadsManager.onAction" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + sandbox.stub(feed.downloadsManager, "onAction"); + + let action = { + type: at.COPY_DOWNLOAD_LINK, + data: { url: "foo.png" }, + _target: {}, + }; + feed.onAction(action); + + Assert.ok( + feed.downloadsManager.onAction.calledOnce, + "HighlightsFeed.downloadManager.onAction called" + ); + Assert.ok(feed.downloadsManager.onAction.calledWith(action)); + sandbox.restore(); +}); + +add_task(async function test_onAction_fetch_highlights_on_SYSTEM_TICK() { + info("HighlightsFeed.onAction should fetch highlights on SYSTEM_TICK"); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + await feed.fetchHighlights(); + + sandbox.spy(feed, "fetchHighlights"); + feed.onAction({ type: at.SYSTEM_TICK }); + + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights called" + ); + Assert.ok( + feed.fetchHighlights.calledWithExactly({ + broadcast: false, + isStartup: false, + }) + ); + sandbox.restore(); +}); + +add_task( + async function test_onAction_fetch_highlights_on_PREF_CHANGED_for_include() { + info( + "HighlightsFeed.onAction should fetch highlights on PREF_CHANGED " + + "for include prefs" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed, "fetchHighlights"); + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "section.highlights.includeBookmarks" }, + }); + + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights called" + ); + Assert.ok(feed.fetchHighlights.calledWithExactly({ broadcast: true })); + sandbox.restore(); + } +); + +add_task( + async function test_onAction_no_fetch_highlights_on_PREF_CHANGED_for_other() { + info( + "HighlightsFeed.onAction should not fetch highlights on PREF_CHANGED " + + "for other prefs" + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + sandbox.spy(feed, "fetchHighlights"); + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "section.topstories.pocketCta" }, + }); + + Assert.ok( + feed.fetchHighlights.notCalled, + "HighlightsFeed.fetchHighlights not called" + ); + + sandbox.restore(); + } +); + +add_task(async function test_onAction_fetch_highlights_on_actions() { + info("HighlightsFeed.onAction should fetch highlights for various actions"); + + let actions = [ + { + actionType: "PLACES_HISTORY_CLEARED", + expectsExpire: false, + expectsBroadcast: true, + }, + { + actionType: "DOWNLOAD_CHANGED", + expectsExpire: false, + expectsBroadcast: true, + }, + { + actionType: "PLACES_LINKS_CHANGED", + expectsExpire: true, + expectsBroadcast: false, + }, + { + actionType: "PLACES_LINK_BLOCKED", + expectsExpire: false, + expectsBroadcast: true, + }, + { + actionType: "PLACES_SAVED_TO_POCKET", + expectsExpire: true, + expectsBroadcast: false, + }, + ]; + for (let action of actions) { + info( + `HighlightsFeed.onAction should fetch highlights on ${action.actionType}` + ); + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + await feed.fetchHighlights(); + sandbox.spy(feed, "fetchHighlights"); + sandbox.stub(feed.linksCache, "expire"); + + feed.onAction({ type: at[action.actionType] }); + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights called" + ); + Assert.ok( + feed.fetchHighlights.calledWith({ broadcast: action.expectsBroadcast }) + ); + + if (action.expectsExpire) { + Assert.ok( + feed.linksCache.expire.calledOnce, + "HighlightsFeed.linksCache.expire called" + ); + } + + sandbox.restore(); + } +}); + +add_task( + async function test_onAction_fetch_highlights_no_broadcast_on_TOP_SITES_UPDATED() { + info( + "HighlightsFeed.onAction should fetch highlights with broadcast " + + "false on TOP_SITES_UPDATED" + ); + + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + await feed.fetchHighlights(); + sandbox.spy(feed, "fetchHighlights"); + + feed.onAction({ type: at.TOP_SITES_UPDATED }); + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights called" + ); + Assert.ok( + feed.fetchHighlights.calledWithExactly({ + broadcast: false, + isStartup: false, + }) + ); + + sandbox.restore(); + } +); + +add_task( + async function test_onAction_fetch_highlights_on_deleting_archiving_pocket() { + info( + "HighlightsFeed.onAction should call fetchHighlights when deleting " + + "or archiving from Pocket" + ); + + let sandbox = sinon.createSandbox(); + let feed = getHighlightsFeedForTest(sandbox); + + await feed.fetchHighlights(); + sandbox.spy(feed, "fetchHighlights"); + + feed.onAction({ + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + data: { pocket_id: 12345 }, + }); + Assert.ok( + feed.fetchHighlights.calledOnce, + "HighlightsFeed.fetchHighlights called" + ); + Assert.ok(feed.fetchHighlights.calledWithExactly({ broadcast: true })); + + sandbox.restore(); + } +); diff --git a/browser/components/newtab/test/xpcshell/test_PlacesFeed.js b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js new file mode 100644 index 0000000000..8e7c42d639 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_PlacesFeed.js @@ -0,0 +1,1812 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { actionTypes: at, actionCreators: ac } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", + PlacesFeed: "resource://activity-stream/lib/PlacesFeed.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchService: "resource://gre/modules/SearchService.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +const { PlacesObserver } = PlacesFeed; + +const FAKE_BOOKMARK = { + bookmarkGuid: "D3r1sKRobtbW", + bookmarkTitle: "Foo", + dateAdded: 123214232, + url: "foo.com", +}; +const TYPE_BOOKMARK = 1; // This is fake, for testing +const SOURCES = { + DEFAULT: 0, + SYNC: 1, + IMPORT: 2, + RESTORE: 5, + RESTORE_ON_STARTUP: 6, +}; + +// The event dispatched in NewTabUtils when a link is blocked; +const BLOCKED_EVENT = "newtab-linkBlocked"; + +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const POCKET_SITE_PREF = "extensions.pocket.site"; + +function getPlacesFeedForTest(sandbox) { + let feed = new PlacesFeed(); + feed.store = { + dispatch: sandbox.spy(), + feeds: { + get: sandbox.stub(), + }, + }; + + sandbox.stub(AboutNewTab, "activityStream").value({ + store: feed.store, + }); + + return feed; +} + +add_task(async function test_construction() { + info("PlacesFeed construction should work"); + let feed = new PlacesFeed(); + Assert.ok(feed, "PlacesFeed could be constructed."); +}); + +add_task(async function test_PlacesObserver() { + info("PlacesFeed should have a PlacesObserver that dispatches to the store"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + + let action = { type: "FOO" }; + feed.placesObserver.dispatch(action); + + await TestUtils.waitForTick(); + Assert.ok(feed.store.dispatch.calledOnce, "PlacesFeed.store dispatch called"); + Assert.equal(feed.store.dispatch.firstCall.args[0].type, action.type); + + sandbox.restore(); +}); + +add_task(async function test_addToBlockedTopSitesSponsors_add_to_blocklist() { + info( + "PlacesFeed.addToBlockedTopSitesSponsors should add the blocked sponsors " + + "to the blocklist" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + feed.addToBlockedTopSitesSponsors([ + { url: "test.com" }, + { url: "test1.com" }, + ]); + + let blockedSponsors = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF) + ); + Assert.deepEqual( + new Set(["foo", "bar", "test", "test1"]), + new Set(blockedSponsors) + ); + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); + +add_task(async function test_addToBlockedTopSitesSponsors_no_dupes() { + info( + "PlacesFeed.addToBlockedTopSitesSponsors should not add duplicate " + + "sponsors to the blocklist" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + feed.addToBlockedTopSitesSponsors([ + { url: "foo.com" }, + { url: "bar.com" }, + { url: "test.com" }, + ]); + + let blockedSponsors = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF) + ); + Assert.deepEqual(new Set(["foo", "bar", "test"]), new Set(blockedSponsors)); + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); + +add_task(async function test_onAction_PlacesEvents() { + info( + "PlacesFeed.onAction should add bookmark, history, places, blocked " + + "observers on INIT" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed.placesObserver, "handlePlacesEvent"); + + feed.onAction({ type: at.INIT }); + // The PlacesObserver registration happens at the next tick of the + // event loop. + await TestUtils.waitForTick(); + + // These are some dummy PlacesEvents that we'll pass through the + // PlacesObserver service, checking that the handlePlacesEvent receives them + // properly. + let notifications = [ + new PlacesBookmarkAddition({ + dateAdded: 0, + guid: "dQFSYrbM5SJN", + id: -1, + index: 0, + isTagging: false, + itemType: 1, + parentGuid: "n_HOEFys1qsL", + parentId: -2, + source: 0, + title: "test-123", + tags: "tags", + url: "http://example.com/test-123", + frecency: 0, + hidden: false, + visitCount: 0, + lastVisitDate: 0, + targetFolderGuid: null, + targetFolderItemId: -1, + targetFolderTitle: null, + }), + new PlacesBookmarkRemoved({ + id: -1, + url: "http://example.com/test-123", + title: "test-123", + itemType: 1, + parentId: -2, + index: 0, + guid: "M3WYgJlm2Jlx", + parentGuid: "DO1f97R4KC3Y", + source: 0, + isTagging: false, + isDescendantRemoval: false, + }), + new PlacesHistoryCleared(), + new PlacesVisitRemoved({ + url: "http://example.com/test-123", + pageGuid: "sPVcW2V4H7Rg", + reason: PlacesVisitRemoved.REASON_DELETED, + transitionType: 0, + isRemovedFromStore: true, + isPartialVisistsRemoval: false, + }), + ]; + + for (let notification of notifications) { + PlacesUtils.observers.notifyListeners([notification]); + Assert.ok( + feed.placesObserver.handlePlacesEvent.calledOnce, + "PlacesFeed.handlePlacesEvent called" + ); + Assert.ok(feed.placesObserver.handlePlacesEvent.calledWith([notification])); + feed.placesObserver.handlePlacesEvent.resetHistory(); + } + + info( + "PlacesFeed.onAction remove bookmark, history, places, blocked " + + "observers, and timers on UNINIT" + ); + + let placesChangedTimerCancel = sandbox.spy(); + feed.placesChangedTimer = { + cancel: placesChangedTimerCancel, + }; + + // Unlike INIT, UNINIT removes the observers synchronously, so no need to + // wait for the event loop to tick around again. + feed.onAction({ type: at.UNINIT }); + + for (let notification of notifications) { + PlacesUtils.observers.notifyListeners([notification]); + Assert.ok( + feed.placesObserver.handlePlacesEvent.notCalled, + "PlacesFeed.handlePlacesEvent not called" + ); + feed.placesObserver.handlePlacesEvent.resetHistory(); + } + + Assert.equal(feed.placesChangedTimer, null); + Assert.ok(placesChangedTimerCancel.calledOnce); + + sandbox.restore(); +}); + +add_task(async function test_onAction_BLOCK_URL() { + info("PlacesFeed.onAction should block a url on BLOCK_URL"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(NewTabUtils.activityStreamLinks, "blockURL"); + + feed.onAction({ + type: at.BLOCK_URL, + data: [{ url: "apple.com", pocket_id: 1234 }], + }); + Assert.ok( + NewTabUtils.activityStreamLinks.blockURL.calledWith({ + url: "apple.com", + pocket_id: 1234, + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_BLOCK_URL_topsites_sponsors() { + info( + "PlacesFeed.onAction BLOCK_URL should update the blocked top " + + "sites sponsors" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "addToBlockedTopSitesSponsors"); + + feed.onAction({ + type: at.BLOCK_URL, + data: [{ url: "foo.com", pocket_id: 1234, isSponsoredTopSite: 1 }], + }); + Assert.ok(feed.addToBlockedTopSitesSponsors.calledWith([{ url: "foo.com" }])); + + sandbox.restore(); +}); + +add_task(async function test_onAction_BOOKMARK_URL() { + info("PlacesFeed.onAction should bookmark a url on BOOKMARK_URL"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(NewTabUtils.activityStreamLinks, "addBookmark"); + + let data = { url: "pear.com", title: "A pear" }; + let _target = { browser: { ownerGlobal() {} } }; + feed.onAction({ type: at.BOOKMARK_URL, data, _target }); + Assert.ok( + NewTabUtils.activityStreamLinks.addBookmark.calledWith( + data, + _target.browser.ownerGlobal + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_DELETE_BOOKMARK_BY_ID() { + info("PlacesFeed.onAction should delete a bookmark on DELETE_BOOKMARK_BY_ID"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(NewTabUtils.activityStreamLinks, "deleteBookmark"); + + feed.onAction({ type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd" }); + Assert.ok( + NewTabUtils.activityStreamLinks.deleteBookmark.calledWith("g123kd") + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_DELETE_HISTORY_URL() { + info( + "PlacesFeed.onAction should delete a history entry on DELETE_HISTORY_URL" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(NewTabUtils.activityStreamLinks, "deleteHistoryEntry"); + sandbox.stub(NewTabUtils.activityStreamLinks, "blockURL"); + + feed.onAction({ + type: at.DELETE_HISTORY_URL, + data: { url: "guava.com", forceBlock: null }, + }); + Assert.ok( + NewTabUtils.activityStreamLinks.deleteHistoryEntry.calledWith("guava.com") + ); + Assert.ok(NewTabUtils.activityStreamLinks.blockURL.notCalled); + + sandbox.restore(); +}); + +add_task(async function test_onAction_DELETE_HISTORY_URL_and_block() { + info( + "PlacesFeed.onAction should delete a history entry on " + + "DELETE_HISTORY_URL and force a site to be blocked if specified" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(NewTabUtils.activityStreamLinks, "deleteHistoryEntry"); + sandbox.stub(NewTabUtils.activityStreamLinks, "blockURL"); + + feed.onAction({ + type: at.DELETE_HISTORY_URL, + data: { url: "guava.com", forceBlock: "g123kd" }, + }); + Assert.ok( + NewTabUtils.activityStreamLinks.deleteHistoryEntry.calledWith("guava.com") + ); + Assert.ok( + NewTabUtils.activityStreamLinks.blockURL.calledWith({ + url: "guava.com", + pocket_id: undefined, + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_NEW_WINDOW() { + info( + "PlacesFeed.onAction should call openTrustedLinkIn with the " + + "correct url, where and params on OPEN_NEW_WINDOW" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openWindowAction = { + type: at.OPEN_NEW_WINDOW, + data: { url: "https://foo.com" }, + _target: { browser: { ownerGlobal: { openTrustedLinkIn } } }, + }; + + feed.onAction(openWindowAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where, params] = openTrustedLinkIn.firstCall.args; + Assert.equal(url, "https://foo.com"); + Assert.equal(where, "window"); + Assert.ok(!params.private); + Assert.ok(!params.forceForeground); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_PRIVATE_WINDOW() { + info( + "PlacesFeed.onAction should call openTrustedLinkIn with the " + + "correct url, where, params and privacy args on OPEN_PRIVATE_WINDOW" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openWindowAction = { + type: at.OPEN_PRIVATE_WINDOW, + data: { url: "https://foo.com" }, + _target: { browser: { ownerGlobal: { openTrustedLinkIn } } }, + }; + + feed.onAction(openWindowAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where, params] = openTrustedLinkIn.firstCall.args; + Assert.equal(url, "https://foo.com"); + Assert.equal(where, "window"); + Assert.ok(params.private); + Assert.ok(!params.forceForeground); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_LINK() { + info( + "PlacesFeed.onAction should call openTrustedLinkIn with the " + + "correct url, where and params on OPEN_LINK" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openLinkAction = { + type: at.OPEN_LINK, + data: { url: "https://foo.com" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + feed.onAction(openLinkAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where, params] = openTrustedLinkIn.firstCall.args; + Assert.equal(url, "https://foo.com"); + Assert.equal(where, "current"); + Assert.ok(!params.private); + Assert.ok(!params.forceForeground); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_LINK_referrer() { + info("PlacesFeed.onAction should open link with referrer on OPEN_LINK"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openLinkAction = { + type: at.OPEN_LINK, + data: { url: "https://foo.com", referrer: "https://foo.com/ref" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" }, + }, + }, + }; + feed.onAction(openLinkAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [, , params] = openTrustedLinkIn.firstCall.args; + Assert.equal(params.referrerInfo.referrerPolicy, 5); + Assert.equal( + params.referrerInfo.originalReferrer.spec, + "https://foo.com/ref" + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_LINK_typed_bonus() { + info( + "PlacesFeed.onAction should mark link with typed bonus as " + + "typed before opening OPEN_LINK" + ); + let sandbox = sinon.createSandbox(); + let callOrder = []; + // We can't stub out PlacesUtils.history.markPageAsTyped, since that's an + // XPCOM component. We'll stub out history instead. + sandbox.stub(PlacesUtils, "history").get(() => { + return { + markPageAsTyped: sandbox.stub().callsFake(() => { + callOrder.push("markPageAsTyped"); + }), + }; + }); + + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub().callsFake(() => { + callOrder.push("openTrustedLinkIn"); + }); + let openLinkAction = { + type: at.OPEN_LINK, + data: { + typedBonus: true, + url: "https://foo.com", + }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" }, + }, + }, + }; + feed.onAction(openLinkAction); + + Assert.deepEqual(callOrder, ["markPageAsTyped", "openTrustedLinkIn"]); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_LINK_pocket() { + info( + "PlacesFeed.onAction should open the pocket link if it's a " + + "pocket story on OPEN_LINK" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openLinkAction = { + type: at.OPEN_LINK, + data: { + url: "https://foo.com", + open_url: "https://getpocket.com/foo", + type: "pocket", + }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where, params] = openTrustedLinkIn.firstCall.args; + Assert.equal(url, "https://getpocket.com/foo"); + Assert.equal(where, "current"); + Assert.ok(!params.private); + Assert.ok(!params.forceForeground); + + sandbox.restore(); +}); + +add_task(async function test_onAction_OPEN_LINK_not_http() { + info("PlacesFeed.onAction should not open link if not http"); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openLinkAction = { + type: at.OPEN_LINK, + data: { url: "file:///foo.com" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + Assert.ok(openTrustedLinkIn.notCalled, "openTrustedLinkIn not called"); + + sandbox.restore(); +}); + +add_task(async function test_onAction_FILL_SEARCH_TERM() { + info( + "PlacesFeed.onAction should call fillSearchTopSiteTerm " + + "on FILL_SEARCH_TERM" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "fillSearchTopSiteTerm"); + + feed.onAction({ type: at.FILL_SEARCH_TERM }); + + Assert.ok( + feed.fillSearchTopSiteTerm.calledOnce, + "PlacesFeed.fillSearchTopSiteTerm called" + ); + sandbox.restore(); +}); + +add_task(async function test_onAction_ABOUT_SPONSORED_TOP_SITES() { + info( + "PlacesFeed.onAction should call openTrustedLinkIn with the " + + "correct SUMO url on ABOUT_SPONSORED_TOP_SITES" + ); + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + let openTrustedLinkIn = sandbox.stub(); + let openLinkAction = { + type: at.ABOUT_SPONSORED_TOP_SITES, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn }, + }, + }, + }; + + feed.onAction(openLinkAction); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where] = openTrustedLinkIn.firstCall.args; + Assert.ok(url.endsWith("sponsor-privacy")); + Assert.equal(where, "tab"); + + sandbox.restore(); +}); + +add_task(async function test_onAction_FILL_SEARCH_TERM() { + info( + "PlacesFeed.onAction should set the URL bar value to the label value " + + "on FILL_SEARCH_TERM" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(SearchService.prototype, "getEngineByAlias").resolves(null); + + let feed = getPlacesFeedForTest(sandbox); + let locationBar = { search: sandbox.stub() }; + let action = { + type: at.FILL_SEARCH_TERM, + data: { label: "@Foo" }, + _target: { browser: { ownerGlobal: { gURLBar: locationBar } } }, + }; + + await feed.onAction(action); + + Assert.ok(locationBar.search.calledOnce, "gURLBar.search called"); + Assert.ok( + locationBar.search.calledWithExactly("@Foo", { + searchEngine: null, + searchModeEntry: "topsites_newtab", + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_SAVE_TO_POCKET() { + info("PlacesFeed.onAction should call saveToPocket on SAVE_TO_POCKET"); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "saveToPocket"); + + let action = { + type: at.SAVE_TO_POCKET, + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + + await feed.onAction(action); + + Assert.ok(feed.saveToPocket.calledOnce, "PlacesFeed.saveToPocket called"); + Assert.ok( + feed.saveToPocket.calledWithExactly( + action.data.site, + action._target.browser + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_SAVE_TO_POCKET_not_logged_in() { + info( + "PlacesFeed.onAction should openTrustedLinkIn with sendToPocket " + + "if not logged in on SAVE_TO_POCKET" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(pktApi, "isUserLoggedIn").returns(false); + sandbox.stub(NimbusFeatures.pocketNewtab, "getVariable").returns(true); + sandbox.stub(ExperimentAPI, "getExperiment").returns({ + slug: "slug", + branch: { slug: "branch-slug" }, + }); + Services.prefs.setStringPref(POCKET_SITE_PREF, "getpocket.com"); + + let feed = getPlacesFeedForTest(sandbox); + + let openTrustedLinkIn = sandbox.stub(); + let action = { + type: at.SAVE_TO_POCKET, + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + + await feed.onAction(action); + + Assert.ok(openTrustedLinkIn.calledOnce, "openTrustedLinkIn called"); + let [url, where] = openTrustedLinkIn.firstCall.args; + Assert.equal( + url, + "https://getpocket.com/signup?utm_source=firefox_newtab_save_button&utm_campaign=slug&utm_content=branch-slug" + ); + Assert.equal(where, "tab"); + + Services.prefs.clearUserPref(POCKET_SITE_PREF); + + sandbox.restore(); +}); + +add_task(async function test_onAction_SAVE_TO_POCKET_logged_in() { + info( + "PlacesFeed.onAction should call " + + "NewTabUtils.activityStreamLinks.addPocketEntry if we are saving a " + + "pocket story on SAVE_TO_POCKET" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(pktApi, "isUserLoggedIn").returns(true); + sandbox.stub(NewTabUtils.activityStreamLinks, "addPocketEntry"); + + let feed = getPlacesFeedForTest(sandbox); + + let openTrustedLinkIn = sandbox.stub(); + let action = { + type: at.SAVE_TO_POCKET, + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + + await feed.onAction(action); + + Assert.ok( + NewTabUtils.activityStreamLinks.addPocketEntry.calledOnce, + "NewTabUtils.activityStreamLinks.addPocketEntry called" + ); + Assert.ok( + NewTabUtils.activityStreamLinks.addPocketEntry.calledWithExactly( + action.data.site.url, + action.data.site.title, + action._target.browser + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_saveToPocket_addPocketEntry_rejects() { + info( + "PlacesFeed.saveToPocket should still resolve if " + + "NewTabUtils.activityStreamLinks.addPocketEntry rejects" + ); + let sandbox = sinon.createSandbox(); + let e = new Error("Error"); + + sandbox.stub(NewTabUtils.activityStreamLinks, "addPocketEntry").rejects(e); + + let feed = getPlacesFeedForTest(sandbox); + + let openTrustedLinkIn = sandbox.stub(); + let action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + + try { + await feed.saveToPocket(action.data.site, action._target.browser); + Assert.ok(true, "PlacesFeed.saveToPocket Promise resolved"); + } catch { + Assert.ok(false, "PlacesFeed.saveToPocket Promise rejected"); + } + + sandbox.restore(); +}); + +add_task(async function test_saveToPocket_broadcast_to_content() { + info( + "PlacesFeed.saveToPocket should broadcast to content if we " + + "successfully added a link to Pocket" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(pktApi, "isUserLoggedIn").returns(true); + + sandbox + .stub(NewTabUtils.activityStreamLinks, "addPocketEntry") + .resolves({ item: { open_url: "pocket.com/itemID", item_id: 1234 } }); + + let feed = getPlacesFeedForTest(sandbox); + + let openTrustedLinkIn = sandbox.stub(); + let action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + + await feed.saveToPocket(action.data.site, action._target.browser); + Assert.ok( + feed.store.dispatch.calledOnce, + "PlacesFeed.store.dispatch was called" + ); + Assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PLACES_SAVED_TO_POCKET + ); + Assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "raspberry.com", + title: "raspberry", + pocket_id: 1234, + open_url: "pocket.com/itemID", + }); + + sandbox.restore(); +}); + +add_task(async function test_saveToPocket_broadcast_only_on_data() { + info( + "PlacesFeed.saveToPocket should broadcast to content if we " + + "successfully added a link to Pocket" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(pktApi, "isUserLoggedIn").returns(true); + + sandbox + .stub(NewTabUtils.activityStreamLinks, "addPocketEntry") + .resolves(null); + + let feed = getPlacesFeedForTest(sandbox); + + let openTrustedLinkIn = sandbox.stub(); + let action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + + await feed.saveToPocket(action.data.site, action._target.browser); + Assert.ok( + feed.store.dispatch.notCalled, + "PlacesFeed.store.dispatch was not called" + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_DELETE_FROM_POCKET() { + info( + "PlacesFeed.onAction should call deleteFromPocket on DELETE_FROM_POCKET" + ); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "deleteFromPocket"); + + feed.onAction({ + type: at.DELETE_FROM_POCKET, + data: { pocket_id: 12345 }, + }); + + Assert.ok( + feed.deleteFromPocket.calledOnce, + "PlacesFeed.deleteFromPocket called" + ); + Assert.ok(feed.deleteFromPocket.calledWithExactly(12345)); + + sandbox.restore(); +}); + +add_task(async function test_deleteFromPocket_resolves() { + info( + "PlacesFeed.deleteFromPocket should still resolve if deletePocketEntry " + + "rejects" + ); + let sandbox = sinon.createSandbox(); + let e = new Error("Error"); + sandbox.stub(NewTabUtils.activityStreamLinks, "deletePocketEntry").rejects(e); + + let feed = getPlacesFeedForTest(sandbox); + await feed.deleteFromPocket(12345); + + try { + await feed.deleteFromPocket(12345); + Assert.ok(true, "PlacesFeed.deleteFromPocket Promise resolved"); + } catch { + Assert.ok(false, "PlacesFeed.deleteFromPocket Promise rejected"); + } + + sandbox.restore(); +}); + +add_task(async function test_deleteFromPocket_calls_deletePocketEntry() { + info( + "PlacesFeed.deleteFromPocket should call " + + "NewTabUtils.deletePocketEntry and dispatch " + + "POCKET_LINK_DELETED_OR_ARCHIVED when deleting from Pocket" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.activityStreamLinks, "deletePocketEntry"); + + let feed = getPlacesFeedForTest(sandbox); + await feed.deleteFromPocket(12345); + + Assert.ok( + NewTabUtils.activityStreamLinks.deletePocketEntry.calledOnce, + "NewTabUtils.activityStreamLinks.deletePocketEntry called" + ); + Assert.ok( + NewTabUtils.activityStreamLinks.deletePocketEntry.calledWithExactly(12345) + ); + Assert.ok( + feed.store.dispatch.calledOnce, + "PlacesFeed.store.dispatch was called" + ); + Assert.ok( + feed.store.dispatch.calledWithExactly({ + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_ARCHIVE_FROM_POCKET() { + info( + "PlacesFeed.onAction should call archiveFromPocket on ARCHIVE_FROM_POCKET" + ); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "archiveFromPocket"); + + await feed.onAction({ + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: 12345 }, + }); + + Assert.ok( + feed.archiveFromPocket.calledOnce, + "PlacesFeed.archiveFromPocket called" + ); + Assert.ok(feed.archiveFromPocket.calledWithExactly(12345)); + + sandbox.restore(); +}); + +add_task(async function test_archiveFromPocket_resolves() { + info( + "PlacesFeed.archiveFromPocket should resolve if archivePocketEntry rejects" + ); + let sandbox = sinon.createSandbox(); + let e = new Error("Error"); + sandbox + .stub(NewTabUtils.activityStreamLinks, "archivePocketEntry") + .rejects(e); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "archiveFromPocket"); + + try { + await feed.archiveFromPocket(12345); + Assert.ok(true, "PlacesFeed.archiveFromPocket Promise resolved"); + } catch { + Assert.ok(false, "PlacesFeed.archiveFromPocket Promise rejected"); + } + + sandbox.restore(); +}); + +add_task(async function test_archiveFromPocket_calls_archivePocketEntry() { + info( + "PlacesFeed.archiveFromPocket should call " + + "NewTabUtils.archivePocketEntry and dispatch " + + "POCKET_LINK_DELETED_OR_ARCHIVED when deleting from Pocket" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.activityStreamLinks, "archivePocketEntry"); + + let feed = getPlacesFeedForTest(sandbox); + await feed.archiveFromPocket(12345); + + Assert.ok( + NewTabUtils.activityStreamLinks.archivePocketEntry.calledOnce, + "NewTabUtils.activityStreamLinks.archivePocketEntry called" + ); + Assert.ok( + NewTabUtils.activityStreamLinks.archivePocketEntry.calledWithExactly(12345) + ); + Assert.ok( + feed.store.dispatch.calledOnce, + "PlacesFeed.store.dispatch was called" + ); + Assert.ok( + feed.store.dispatch.calledWithExactly({ + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_HANDOFF_SEARCH_TO_AWESOMEBAR() { + info( + "PlacesFeed.onAction should call handoffSearchToAwesomebar " + + "on HANDOFF_SEARCH_TO_AWESOMEBAR" + ); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "handoffSearchToAwesomebar"); + + let action = { + type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, + data: { text: "f" }, + meta: { fromTarget: {} }, + _target: { browser: { ownerGlobal: { gURLBar: { focus: () => {} } } } }, + }; + + await feed.onAction(action); + + Assert.ok( + feed.handoffSearchToAwesomebar.calledOnce, + "PlacesFeed.handoffSearchToAwesomebar called" + ); + Assert.ok(feed.handoffSearchToAwesomebar.calledWithExactly(action)); + + sandbox.restore(); +}); + +add_task(async function test_onAction_PARTNER_LINK_ATTRIBUTION() { + info( + "PlacesFeed.onAction should call makeAttributionRequest on " + + "PARTNER_LINK_ATTRIBUTION" + ); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(feed, "makeAttributionRequest"); + + let data = { targetURL: "https://partnersite.com", source: "topsites" }; + feed.onAction({ + type: at.PARTNER_LINK_ATTRIBUTION, + data, + }); + + Assert.ok( + feed.makeAttributionRequest.calledOnce, + "PlacesFeed.makeAttributionRequest called" + ); + Assert.ok(feed.makeAttributionRequest.calledWithExactly(data)); + + sandbox.restore(); +}); + +add_task( + async function test_makeAttributionRequest_PartnerLinkAttribution_makeReq() { + info( + "PlacesFeed.makeAttributionRequest should call " + + "PartnerLinkAttribution.makeRequest" + ); + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + sandbox.stub(PartnerLinkAttribution, "makeRequest"); + + let data = { targetURL: "https://partnersite.com", source: "topsites" }; + feed.makeAttributionRequest(data); + + Assert.ok( + PartnerLinkAttribution.makeRequest.calledOnce, + "PartnerLinkAttribution.makeRequest called" + ); + + sandbox.restore(); + } +); + +function createFakeURLBar(sandbox) { + let fakeURLBar = { + focus: sandbox.spy(), + handoff: sandbox.spy(), + setHiddenFocus: sandbox.spy(), + removeHiddenFocus: sandbox.spy(), + addEventListener: (ev, cb) => { + fakeURLBar.listeners[ev] = cb; + }, + removeEventListener: sandbox.spy(), + + listeners: [], + }; + + return fakeURLBar; +} + +add_task(async function test_handoffSearchToAwesomebar_no_text() { + info( + "PlacesFeed.handoffSearchToAwesomebar should properly handle handoff " + + "with no text passed in" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + let fakeURLBar = createFakeURLBar(sandbox); + + sandbox.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); + sandbox.stub(feed, "_getDefaultSearchEngine").returns(null); + + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeURLBar } } }, + data: {}, + meta: { fromTarget: {} }, + }); + + Assert.ok( + fakeURLBar.setHiddenFocus.calledOnce, + "gURLBar.setHiddenFocus called" + ); + Assert.ok(fakeURLBar.handoff.notCalled, "gURLBar.handoff not called"); + Assert.ok( + feed.store.dispatch.notCalled, + "PlacesFeed.store.dispatch not called" + ); + + // Now type a character. + fakeURLBar.listeners.keydown({ key: "f" }); + Assert.ok(fakeURLBar.handoff.calledOnce, "gURLBar.handoff called"); + Assert.ok( + fakeURLBar.removeHiddenFocus.calledOnce, + "gURLBar.removeHiddenFocus called" + ); + Assert.ok(feed.store.dispatch.calledOnce, "PlacesFeed.store.dispatch called"); + Assert.ok( + feed.store.dispatch.calledWith({ + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "DISABLE_SEARCH", + }), + "PlacesFeed.store.dispatch called" + ); + + sandbox.restore(); +}); + +add_task(async function test_handoffSearchToAwesomebar_with_text() { + info( + "PlacesFeed.handoffSearchToAwesomebar should properly handle handoff " + + "with text data passed in" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + let fakeURLBar = createFakeURLBar(sandbox); + + sandbox.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); + let engine = {}; + sandbox.stub(feed, "_getDefaultSearchEngine").returns(engine); + + const SESSION_ID = "decafc0ffee"; + AboutNewTab.activityStream.store.feeds.get.returns({ + sessions: { + get: () => { + return { session_id: SESSION_ID }; + }, + }, + }); + + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeURLBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + + Assert.ok(fakeURLBar.handoff.calledOnce, "gURLBar.handoff was called"); + Assert.ok(fakeURLBar.handoff.calledWithExactly("foo", engine, SESSION_ID)); + Assert.ok(fakeURLBar.focus.notCalled, "gURLBar.focus not called"); + Assert.ok( + fakeURLBar.setHiddenFocus.notCalled, + "gURLBar.setHiddenFocus not called" + ); + + // Now call blur listener. + fakeURLBar.listeners.blur(); + Assert.ok(feed.store.dispatch.calledOnce, "PlacesFeed.store.dispatch called"); + Assert.ok( + feed.store.dispatch.calledWith({ + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_handoffSearchToAwesomebar_with_text_pb_mode() { + info( + "PlacesFeed.handoffSearchToAwesomebar should properly handle handoff " + + "with text data passed in, in private browsing mode" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + let fakeURLBar = createFakeURLBar(sandbox); + + sandbox.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(true); + let engine = {}; + sandbox.stub(feed, "_getDefaultSearchEngine").returns(engine); + + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeURLBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + Assert.ok(fakeURLBar.handoff.calledOnce, "gURLBar.handoff was called"); + Assert.ok(fakeURLBar.handoff.calledWithExactly("foo", engine, undefined)); + Assert.ok(fakeURLBar.focus.notCalled, "gURLBar.focus not called"); + Assert.ok( + fakeURLBar.setHiddenFocus.notCalled, + "gURLBar.setHiddenFocus not called" + ); + + // Now call blur listener. + fakeURLBar.listeners.blur(); + Assert.ok(feed.store.dispatch.calledOnce, "PlacesFeed.store.dispatch called"); + Assert.ok( + feed.store.dispatch.calledWith({ + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }) + ); + + sandbox.restore(); +}); + +add_task(async function test_handoffSearchToAwesomebar_SHOW_SEARCH_on_esc() { + info( + "PlacesFeed.handoffSearchToAwesomebar should SHOW_SEARCH on ESC keydown" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + let fakeURLBar = createFakeURLBar(sandbox); + + sandbox.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); + let engine = {}; + sandbox.stub(feed, "_getDefaultSearchEngine").returns(engine); + + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeURLBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + Assert.ok(fakeURLBar.handoff.calledOnce, "gURLBar.handoff was called"); + Assert.ok(fakeURLBar.handoff.calledWithExactly("foo", engine, undefined)); + Assert.ok(fakeURLBar.focus.notCalled, "gURLBar.focus not called"); + + // Now call ESC keydown. + fakeURLBar.listeners.keydown({ key: "Escape" }); + Assert.ok(feed.store.dispatch.calledOnce, "PlacesFeed.store.dispatch called"); + Assert.ok( + feed.store.dispatch.calledWith({ + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }) + ); + + sandbox.restore(); +}); + +add_task( + async function test_handoffSearchToAwesomebar_with_session_id_no_text() { + info( + "PlacesFeed.handoffSearchToAwesomebar should properly handoff a " + + "newtab session id with no text passed in" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + let fakeURLBar = createFakeURLBar(sandbox); + + sandbox.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); + let engine = {}; + sandbox.stub(feed, "_getDefaultSearchEngine").returns(engine); + + const SESSION_ID = "decafc0ffee"; + AboutNewTab.activityStream.store.feeds.get.returns({ + sessions: { + get: () => { + return { session_id: SESSION_ID }; + }, + }, + }); + + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeURLBar } } }, + data: {}, + meta: { fromTarget: {} }, + }); + + Assert.ok( + fakeURLBar.setHiddenFocus.calledOnce, + "gURLBar.setHiddenFocus was called" + ); + Assert.ok(fakeURLBar.handoff.notCalled, "gURLBar.handoff not called"); + Assert.ok(fakeURLBar.focus.notCalled, "gURLBar.focus not called"); + Assert.ok( + feed.store.dispatch.notCalled, + "PlacesFeed.store.dispatch not called" + ); + + // Now type a character. + fakeURLBar.listeners.keydown({ key: "f" }); + Assert.ok(fakeURLBar.handoff.calledOnce, "gURLBar.handoff was called"); + Assert.ok(fakeURLBar.handoff.calledWithExactly("", engine, SESSION_ID)); + + Assert.ok( + fakeURLBar.removeHiddenFocus.calledOnce, + "gURLBar.removeHiddenFocus was called" + ); + Assert.ok( + feed.store.dispatch.calledOnce, + "PlacesFeed.store.dispatch called" + ); + Assert.ok( + feed.store.dispatch.calledWith({ + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "DISABLE_SEARCH", + }) + ); + + sandbox.restore(); + } +); + +add_task(async function test_observe_dispatch_PLACES_LINK_BLOCKED() { + info( + "PlacesFeed.observe should dispatch a PLACES_LINK_BLOCKED action " + + "with the url of the blocked link" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + feed.observe(null, BLOCKED_EVENT, "foo123.com"); + Assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PLACES_LINK_BLOCKED + ); + Assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "foo123.com", + }); + + sandbox.restore(); +}); + +add_task(async function test_observe_no_dispatch() { + info( + "PlacesFeed.observe should not call dispatch if the topic is something " + + "other than BLOCKED_EVENT" + ); + + let sandbox = sinon.createSandbox(); + + let feed = getPlacesFeedForTest(sandbox); + feed.observe(null, "someotherevent"); + Assert.ok( + feed.store.dispatch.notCalled, + "PlacesFeed.store.dispatch not called" + ); + + sandbox.restore(); +}); + +add_task( + async function test_handlePlacesEvent_dispatch_one_PLACES_LINKS_CHANGED() { + let events = [ + { + message: + "PlacesFeed.handlePlacesEvent should only dispatch 1 PLACES_LINKS_CHANGED action " + + "if many bookmark-added notifications happened at once", + dispatchCallCount: 5, + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesFeed.handlePlacesEvent should only dispatch 1 " + + "PLACES_LINKS_CHANGED action if many onItemRemoved notifications " + + "happened at once", + dispatchCallCount: 5, + event: { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "rTU_oiklsU7D", + parentGuid: "2BzBQXOPFmuU", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + }, + { + message: + "PlacesFeed.handlePlacesEvent should only dispatch 1 " + + "PLACES_LINKS_CHANGED action if any page-removed notifications " + + "happened at once", + dispatchCallCount: 5, + event: { + type: "page-removed", + url: "foo.com", + isRemovedFromStore: true, + }, + }, + ]; + + for (let { message, dispatchCallCount, event } of events) { + info(message); + + let sandbox = sinon.createSandbox(); + let feed = getPlacesFeedForTest(sandbox); + + await feed.placesObserver.handlePlacesEvent([event]); + await feed.placesObserver.handlePlacesEvent([event]); + await feed.placesObserver.handlePlacesEvent([event]); + await feed.placesObserver.handlePlacesEvent([event]); + + Assert.ok(feed.placesChangedTimer, "PlacesFeed dispatch timer created"); + + // Let's speed things up a bit. + feed.placesChangedTimer.delay = 0; + + // Wait for the timer to go off and get cleared + await TestUtils.waitForCondition( + () => !feed.placesChangedTimer, + "PlacesFeed dispatch timer cleared" + ); + + Assert.equal( + feed.store.dispatch.callCount, + dispatchCallCount, + `PlacesFeed.store.dispatch was called ${dispatchCallCount} times` + ); + + Assert.ok( + feed.store.dispatch.withArgs( + ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED }) + ).calledOnce, + "PlacesFeed.store.dispatch called with PLACES_LINKS_CHANGED once" + ); + + sandbox.restore(); + } + } +); + +add_task(async function test_PlacesObserver_dispatches() { + let events = [ + { + message: + "PlacesObserver should dispatch a PLACES_HISTORY_CLEARED action " + + "on history-cleared", + args: { type: "history-cleared" }, + expectedAction: { type: at.PLACES_HISTORY_CLEARED }, + }, + { + message: + "PlacesObserver should dispatch a PLACES_LINKS_DELETED action " + + "with the right url", + args: { + type: "page-removed", + url: "foo.com", + isRemovedFromStore: true, + }, + expectedAction: { + type: at.PLACES_LINKS_DELETED, + data: { urls: ["foo.com"] }, + }, + }, + { + message: + "PlacesObserver should dispatch a PLACES_BOOKMARK_ADDED action with " + + "the bookmark data - http", + args: { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "http://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + expectedAction: { + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid, + bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle, + dateAdded: FAKE_BOOKMARK.dateAdded * 1000, + url: "http://www.foo.com", + }, + }, + }, + { + message: + "PlacesObserver should dispatch a PLACES_BOOKMARK_ADDED action with " + + "the bookmark data - https", + args: { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + expectedAction: { + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid, + bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle, + dateAdded: FAKE_BOOKMARK.dateAdded * 1000, + url: "https://www.foo.com", + }, + }, + }, + ]; + + for (let { message, args, expectedAction } of events) { + info(message); + let sandbox = sinon.createSandbox(); + let dispatch = sandbox.spy(); + let observer = new PlacesObserver(dispatch); + await observer.handlePlacesEvent([args]); + Assert.ok(dispatch.calledWith(expectedAction)); + sandbox.restore(); + } +}); + +add_task(async function test_PlacesObserver_ignores() { + let events = [ + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARK_ADDED action - " + + "not http/https for bookmark-added", + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARK_ADDED action - " + + "has IMPORT source for bookmark-added", + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.IMPORT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARK_ADDED " + + "action - has RESTORE source for bookmark-added", + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.RESTORE, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARK_ADDED " + + "action - has RESTORE_ON_STARTUP source for bookmark-added", + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.RESTORE_ON_STARTUP, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARK_ADDED " + + "action - has SYNC source for bookmark-added", + event: { + itemType: TYPE_BOOKMARK, + source: SOURCES.SYNC, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should ignore events that are not of " + + "TYPE_BOOKMARK for bookmark-added", + event: { + itemType: "nottypebookmark", + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + }, + { + message: + "PlacesObserver should ignore events that are not of " + + "TYPE_BOOKMARK for bookmark-removed", + event: { + id: null, + parentId: null, + index: null, + itemType: "nottypebookmark", + url: null, + guid: "461Z_7daEqIh", + parentGuid: "hkHScG3aI3hh", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARKS_REMOVED " + + "action - has SYNC source for bookmark-removed", + event: { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "uvRE3stjoZOI", + parentGuid: "BnsXZl8VMJjB", + source: SOURCES.SYNC, + type: "bookmark-removed", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARKS_REMOVED " + + "action - has IMPORT source for bookmark-removed", + event: { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "VF6YwhGpHrOW", + parentGuid: "7Vz8v9nKcSoq", + source: SOURCES.IMPORT, + type: "bookmark-removed", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARKS_REMOVED " + + "action - has RESTORE source for bookmark-removed", + event: { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "eKozFyXJP97R", + parentGuid: "ya8Z2FbjKnD0", + source: SOURCES.RESTORE, + type: "bookmark-removed", + }, + }, + { + message: + "PlacesObserver should not dispatch a PLACES_BOOKMARKS_REMOVED " + + "action - has RESTORE_ON_STARTUP source for bookmark-removed", + event: { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "StSGMhrYYfyD", + parentGuid: "vL8wsCe2j_eT", + source: SOURCES.RESTORE_ON_STARTUP, + type: "bookmark-removed", + }, + }, + ]; + + for (let { message, event } of events) { + info(message); + let sandbox = sinon.createSandbox(); + let dispatch = sandbox.spy(); + let observer = new PlacesObserver(dispatch); + + await observer.handlePlacesEvent([event]); + Assert.ok(dispatch.notCalled, "PlacesObserver.dispatch not called"); + sandbox.restore(); + } +}); + +add_task(async function test_PlacesObserver_bookmark_removed() { + info( + "PlacesObserver should dispatch a PLACES_BOOKMARKS_REMOVED " + + "action with the right URL and bookmarkGuid for bookmark-removed" + ); + let sandbox = sinon.createSandbox(); + let dispatch = sandbox.spy(); + let observer = new PlacesObserver(dispatch); + + await observer.handlePlacesEvent([ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "Xgnxs27I9JnX", + parentGuid: "a4k739PL55sP", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + ]); + + Assert.ok( + dispatch.calledWith({ + type: at.PLACES_BOOKMARKS_REMOVED, + data: { urls: ["foo.com"] }, + }) + ); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_Store.js b/browser/components/newtab/test/xpcshell/test_Store.js new file mode 100644 index 0000000000..b05ad36cd6 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_Store.js @@ -0,0 +1,453 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Store } = ChromeUtils.importESModule( + "resource://activity-stream/lib/Store.sys.mjs" +); +const { ActivityStreamMessageChannel } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamMessageChannel.sys.mjs" +); +const { ActivityStreamStorage } = ChromeUtils.importESModule( + "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// This creates the Redux top-level object. +/* globals Redux */ +Services.scriptloader.loadSubScript( + "resource://activity-stream/vendor/redux.js", + this +); + +add_task(async function test_expected_properties() { + let sandbox = sinon.createSandbox(); + let store = new Store(); + + Assert.equal(store.feeds.constructor.name, "Map", "Should create a Map"); + Assert.equal(store.feeds.size, 0, "Store should start without any feeds."); + + Assert.ok(store._store, "Has a ._store"); + Assert.ok(store.dispatch, "Has a .dispatch"); + Assert.ok(store.getState, "Has a .getState"); + + sandbox.restore(); +}); + +add_task(async function test_messagechannel() { + let sandbox = sinon.createSandbox(); + sandbox + .stub(ActivityStreamMessageChannel.prototype, "middleware") + .returns(s => next => action => next(action)); + let store = new Store(); + + info( + "Store should create a ActivityStreamMessageChannel with the right dispatcher" + ); + Assert.ok(store.getMessageChannel(), "Has a message channel"); + Assert.equal( + store.getMessageChannel().dispatch, + store.dispatch, + "MessageChannel.dispatch forwards to store.dispatch" + ); + Assert.equal( + store.getMessageChannel(), + store._messageChannel, + "_messageChannel is the member for getMessageChannel()" + ); + + store.dispatch({ type: "FOO" }); + Assert.ok( + ActivityStreamMessageChannel.prototype.middleware.calledOnce, + "Middleware called." + ); + sandbox.restore(); +}); + +add_task(async function test_initFeed_add_feeds() { + info("Store.initFeed should add an instance of the feed to .feeds"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + class Foo {} + store._prefs.set("foo", true); + await store.init(new Map([["foo", () => new Foo()]])); + store.initFeed("foo"); + + Assert.ok(store.feeds.has("foo"), "foo is set"); + Assert.ok(store.feeds.get("foo") instanceof Foo, "Got registered class"); + sandbox.restore(); +}); + +add_task(async function test_initFeed_calls_onAction() { + info("Store should call the feed's onAction with uninit action if it exists"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + let testFeed; + let createTestFeed = () => { + testFeed = { onAction: sandbox.spy() }; + return testFeed; + }; + const action = { type: "FOO" }; + store._feedFactories = new Map([["test", createTestFeed]]); + + store.initFeed("test", action); + + Assert.ok(testFeed.onAction.calledOnce, "onAction called"); + Assert.ok( + testFeed.onAction.calledWith(action), + "onAction called with test action" + ); + + info("Store should add a .store property to the feed"); + Assert.ok(testFeed.store, "Store exists"); + Assert.equal(testFeed.store, store, "Feed store is the Store"); + sandbox.restore(); +}); + +add_task(async function test_initFeed_on_init() { + info("Store should call .initFeed with each key"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.stub(store, "initFeed"); + store._prefs.set("foo", true); + store._prefs.set("bar", true); + await store.init( + new Map([ + ["foo", () => {}], + ["bar", () => {}], + ]) + ); + Assert.ok(store.initFeed.calledWith("foo"), "First test feed initted"); + Assert.ok(store.initFeed.calledWith("bar"), "Second test feed initted"); + sandbox.restore(); +}); + +add_task(async function test_initFeed_calls__initIndexedDB() { + info("Store should call _initIndexedDB"); + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.spy(store, "_initIndexedDB"); + + let dbStub = sandbox.stub(ActivityStreamStorage.prototype, "db"); + let dbAccessed = false; + dbStub.get(() => { + dbAccessed = true; + return {}; + }); + + store._prefs.set("testfeed", true); + await store.init( + new Map([ + [ + "testfeed", + () => { + return {}; + }, + ], + ]) + ); + + Assert.ok(store._initIndexedDB.calledOnce, "_initIndexedDB called once"); + Assert.ok( + store._initIndexedDB.calledWithExactly("feeds.telemetry"), + "feeds.telemetry was passed" + ); + // Due to what appears to be a bug in sinon when using calledOnce + // with a stubbed getter, we can't just use dbStub.calledOnce here. + Assert.ok(dbAccessed, "ActivityStreamStorage was accessed"); + + info( + "Store should reset ActivityStreamStorage telemetry if opening the db fails" + ); + dbStub.rejects(); + await store.init(new Map()); + + Assert.equal( + store.dbStorage.telemetry, + null, + "Telemetry on storage was cleared" + ); + + sandbox.restore(); +}); + +add_task(async function test_disabled_feed() { + info("Store should not initialize the feed if the Pref is set to false"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.stub(store, "initFeed"); + store._prefs.set("foo", false); + await store.init(new Map([["foo", () => {}]])); + Assert.ok(store.initFeed.notCalled, ".initFeed not called"); + + store._prefs.set("foo", true); + + sandbox.restore(); +}); + +add_task(async function test_observe_pref_branch() { + info("Store should observe the pref branch"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.stub(store._prefs, "observeBranch"); + await store.init(new Map()); + Assert.ok(store._prefs.observeBranch.calledOnce, "observeBranch called once"); + Assert.ok( + store._prefs.observeBranch.calledWith(store), + "observeBranch passed the store" + ); + + sandbox.restore(); +}); + +add_task(async function test_emit_initial_event() { + info("Store should emit an initial event if provided"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + const action = { type: "FOO" }; + sandbox.stub(store, "dispatch"); + await store.init(new Map(), action); + Assert.ok(store.dispatch.calledOnce, "Dispatch called once"); + Assert.ok(store.dispatch.calledWith(action), "Dispatch called with action"); + + sandbox.restore(); +}); + +add_task(async function test_initialize_telemetry_feed_first() { + info("Store should initialize the telemetry feed first"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + store._prefs.set("feeds.foo", true); + store._prefs.set("feeds.telemetry", true); + const telemetrySpy = sandbox.stub().returns({}); + const fooSpy = sandbox.stub().returns({}); + // Intentionally put the telemetry feed as the second item. + const feedFactories = new Map([ + ["feeds.foo", fooSpy], + ["feeds.telemetry", telemetrySpy], + ]); + await store.init(feedFactories); + Assert.ok(telemetrySpy.calledBefore(fooSpy), "Telemetry feed initted first"); + + sandbox.restore(); +}); + +add_task(async function test_dispatch_init_load_events() { + info("Store should dispatch init/load events"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.stub(store.getMessageChannel(), "simulateMessagesForExistingTabs"); + await store.init(new Map(), { type: "FOO" }); + Assert.ok( + store.getMessageChannel().simulateMessagesForExistingTabs.calledOnce, + "simulateMessagesForExistingTabs called once" + ); + + sandbox.restore(); +}); + +add_task(async function test_init_before_load() { + info("Store should dispatch INIT before LOAD"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + sandbox.stub(store.getMessageChannel(), "simulateMessagesForExistingTabs"); + sandbox.stub(store, "dispatch"); + const init = { type: "INIT" }; + const load = { type: "TAB_LOAD" }; + store + .getMessageChannel() + .simulateMessagesForExistingTabs.callsFake(() => store.dispatch(load)); + await store.init(new Map(), init); + + Assert.ok(store.dispatch.calledTwice, "Dispatch called twice"); + Assert.equal( + store.dispatch.firstCall.args[0], + init, + "First dispatch was for init event" + ); + Assert.equal( + store.dispatch.secondCall.args[0], + load, + "Second dispatch was for load event" + ); + + sandbox.restore(); +}); + +add_task(async function test_uninit_feeds() { + info("uninitFeed should not throw if no feed with that name exists"); + + let sandbox = sinon.createSandbox(); + let store = new Store(); + + try { + store.uninitFeed("does-not-exist"); + Assert.ok(true, "Didn't throw"); + } catch (e) { + Assert.ok(false, "Should not have thrown"); + } + + info( + "uninitFeed should call the feed's onAction with uninit action if it exists" + ); + let feed; + function createFeed() { + feed = { onAction: sandbox.spy() }; + return feed; + } + const action = { type: "BAR" }; + store._feedFactories = new Map([["foo", createFeed]]); + store.initFeed("foo"); + + store.uninitFeed("foo", action); + + Assert.ok(feed.onAction.calledOnce); + Assert.ok(feed.onAction.calledWith(action)); + + info("uninitFeed should remove the feed from .feeds"); + Assert.ok(!store.feeds.has("foo"), "foo is not in .feeds"); + + sandbox.restore(); +}); + +add_task(async function test_onPrefChanged() { + let sandbox = sinon.createSandbox(); + let store = new Store(); + let initFeedStub = sandbox.stub(store, "initFeed"); + let uninitFeedStub = sandbox.stub(store, "uninitFeed"); + store._prefs.set("foo", false); + store.init(new Map([["foo", () => ({})]])); + + info("onPrefChanged should initialize the feed if called with true"); + store.onPrefChanged("foo", true); + Assert.ok(initFeedStub.calledWith("foo")); + Assert.ok(!uninitFeedStub.calledOnce); + initFeedStub.resetHistory(); + uninitFeedStub.resetHistory(); + + info("onPrefChanged should uninitialize the feed if called with false"); + store.onPrefChanged("foo", false); + Assert.ok(uninitFeedStub.calledWith("foo")); + Assert.ok(!initFeedStub.calledOnce); + initFeedStub.resetHistory(); + uninitFeedStub.resetHistory(); + + info("onPrefChanged should do nothing if not an expected feed"); + store.onPrefChanged("bar", false); + + Assert.ok(!initFeedStub.calledOnce); + Assert.ok(!uninitFeedStub.calledOnce); + sandbox.restore(); +}); + +add_task(async function test_uninit() { + let sandbox = sinon.createSandbox(); + let store = new Store(); + let dispatchStub = sandbox.stub(store, "dispatch"); + const action = { type: "BAR" }; + await store.init(new Map(), null, action); + store.uninit(); + + Assert.ok(store.dispatch.calledOnce); + Assert.ok(store.dispatch.calledWith(action)); + + info("Store.uninit should clear .feeds and ._feedFactories"); + store._prefs.set("a", true); + await store.init( + new Map([ + ["a", () => ({})], + ["b", () => ({})], + ["c", () => ({})], + ]) + ); + + store.uninit(); + + Assert.equal(store.feeds.size, 0); + Assert.equal(store._feedFactories, null); + + info("Store.uninit should emit an uninit event if provided on init"); + dispatchStub.resetHistory(); + const uninitAction = { type: "BAR" }; + await store.init(new Map(), null, uninitAction); + store.uninit(); + + Assert.ok(store.dispatch.calledOnce); + Assert.ok(store.dispatch.calledWith(uninitAction)); + sandbox.restore(); +}); + +add_task(async function test_getState() { + info("Store.getState should return the redux state"); + let sandbox = sinon.createSandbox(); + let store = new Store(); + store._store = Redux.createStore((prevState = 123) => prevState); + const { getState } = store; + Assert.equal(getState(), 123); + sandbox.restore(); +}); + +/** + * addNumberReducer - a simple dummy reducer for testing that adds a number + */ +function addNumberReducer(prevState = 0, action) { + return action.type === "ADD" ? prevState + action.data : prevState; +} + +add_task(async function test_dispatch() { + info("Store.dispatch should call .onAction of each feed"); + let sandbox = sinon.createSandbox(); + let store = new Store(); + const { dispatch } = store; + const sub = { onAction: sinon.spy() }; + const action = { type: "FOO" }; + + store._prefs.set("sub", true); + await store.init(new Map([["sub", () => sub]])); + + dispatch(action); + + Assert.ok(sub.onAction.calledWith(action)); + + info("Sandbox.dispatch should call the reducers"); + + store._store = Redux.createStore(addNumberReducer); + dispatch({ type: "ADD", data: 14 }); + Assert.equal(store.getState(), 14); + + sandbox.restore(); +}); + +add_task(async function test_subscribe() { + info("Store.subscribe should subscribe to changes to the store"); + let sandbox = sinon.createSandbox(); + let store = new Store(); + const sub = sandbox.spy(); + const action = { type: "FOO" }; + + store.subscribe(sub); + store.dispatch(action); + + Assert.ok(sub.calledOnce); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js b/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js new file mode 100644 index 0000000000..b54d6094ad --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_TelemetryFeed.js @@ -0,0 +1,3285 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.sys.mjs" +); + +const { MESSAGE_TYPE_HASH: msg } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ActorConstants.sys.mjs" +); + +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); + +const { TelemetryFeed, USER_PREFS_ENCODING } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TelemetryFeed.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + AboutWelcomeTelemetry: + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + TelemetryController: "resource://gre/modules/TelemetryController.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", + UTEventReporting: "resource://activity-stream/lib/UTEventReporting.sys.mjs", +}); + +const FAKE_UUID = "{foo-123-foo}"; +const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId"; +const PREF_TELEMETRY = "browser.newtabpage.activity-stream.telemetry"; +const PREF_EVENT_TELEMETRY = + "browser.newtabpage.activity-stream.telemetry.ut.events"; + +let ASRouterEventPingSchemaPromise; +let BasePingSchemaPromise; +let SessionPingSchemaPromise; +let UserEventPingSchemaPromise; + +function assertPingMatchesSchema(pingKind, ping, schema) { + // Unlike the validator from JsonSchema.sys.mjs, JsonSchemaValidator + // lets us opt-in to having "undefined" properties, which are then + // ignored. This is fine because the ping is sent as a JSON string + // over an XHR, and undefined properties are culled as part of the + // JSON encoding process. + let result = JsonSchemaValidator.validate(ping, schema, { + allowExplicitUndefinedProperties: true, + }); + + if (!result.valid) { + info(`${pingKind} failed to validate against the schema: ${result.error}`); + } + + Assert.ok(result.valid, `${pingKind} is valid against the schema.`); +} + +async function assertSessionPingValid(ping) { + let schema = await SessionPingSchemaPromise; + assertPingMatchesSchema("SessionPing", ping, schema); +} + +async function assertBasePingValid(ping) { + let schema = await BasePingSchemaPromise; + assertPingMatchesSchema("BasePing", ping, schema); +} + +async function assertUserEventPingValid(ping) { + let schema = await UserEventPingSchemaPromise; + assertPingMatchesSchema("UserEventPing", ping, schema); +} + +async function assertASRouterEventPingValid(ping) { + let schema = await ASRouterEventPingSchemaPromise; + assertPingMatchesSchema("ASRouterEventPing", ping, schema); +} + +add_setup(async function setup() { + ASRouterEventPingSchemaPromise = IOUtils.readJSON( + do_get_file("../schemas/asrouter_event_ping.schema.json").path + ); + + BasePingSchemaPromise = IOUtils.readJSON( + do_get_file("../schemas/base_ping.schema.json").path + ); + + SessionPingSchemaPromise = IOUtils.readJSON( + do_get_file("../schemas/session_ping.schema.json").path + ); + + UserEventPingSchemaPromise = IOUtils.readJSON( + do_get_file("../schemas/user_event_ping.schema.json").path + ); + + do_get_profile(); + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); + + await TelemetryController.testReset(); + + updateAppInfo({ + name: "XPCShell", + ID: "xpcshell@tests.mozilla.org", + version: "122", + platformVersion: "122", + }); + + Services.prefs.setCharPref( + "browser.contextual-services.contextId", + FAKE_UUID + ); +}); + +add_task(async function test_construction() { + let testInstance = new TelemetryFeed(); + Assert.ok( + testInstance, + "Should have been able to create an instance of TelemetryFeed." + ); + Assert.ok( + testInstance.utEvents instanceof UTEventReporting, + "Should add .utEvents, a UTEventReporting instance." + ); + Assert.ok( + testInstance._impressionId, + "Should create impression id if none exists" + ); +}); + +add_task(async function test_load_impressionId() { + info( + "Constructing a TelemetryFeed should use a saved impression ID if one exists." + ); + const FAKE_IMPRESSION_ID = "{some-fake-impression-ID}"; + const IMPRESSION_PREF = "browser.newtabpage.activity-stream.impressionId"; + Services.prefs.setCharPref(IMPRESSION_PREF, FAKE_IMPRESSION_ID); + Assert.equal(new TelemetryFeed()._impressionId, FAKE_IMPRESSION_ID); + Services.prefs.clearUserPref(IMPRESSION_PREF); +}); + +add_task(async function test_init() { + info( + "init should make this.browserOpenNewtabStart() observe browser-open-newtab-start" + ); + let sandbox = sinon.createSandbox(); + sandbox + .stub(TelemetryFeed.prototype, "getOrCreateImpressionId") + .returns(FAKE_UUID); + + let instance = new TelemetryFeed(); + sandbox.stub(instance, "browserOpenNewtabStart"); + instance.init(); + + Services.obs.notifyObservers(null, "browser-open-newtab-start"); + Assert.ok( + instance.browserOpenNewtabStart.calledOnce, + "browserOpenNewtabStart called once." + ); + + info("init should create impression id if none exists"); + Assert.equal(instance._impressionId, FAKE_UUID); + + instance.uninit(); + sandbox.restore(); +}); + +add_task(async function test_saved_impression_id() { + const FAKE_IMPRESSION_ID = "fakeImpressionId"; + Services.prefs.setCharPref(PREF_IMPRESSION_ID, FAKE_IMPRESSION_ID); + Assert.equal(new TelemetryFeed()._impressionId, FAKE_IMPRESSION_ID); + Services.prefs.clearUserPref(PREF_IMPRESSION_ID); +}); + +add_task(async function test_telemetry_prefs() { + info("Telemetry pref changes from false to true"); + Services.prefs.setBoolPref(PREF_TELEMETRY, false); + let instance = new TelemetryFeed(); + Assert.ok(!instance.telemetryEnabled, "Telemetry disabled"); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + Assert.ok(instance.telemetryEnabled, "Telemetry enabled"); + + info("Event telemetry pref changes from false to true"); + + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, false); + Assert.ok(!instance.eventTelemetryEnabled, "Event telemetry disabled"); + + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + Assert.ok(instance.eventTelemetryEnabled, "Event telemetry enabled"); + + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + Services.prefs.clearUserPref(PREF_TELEMETRY); +}); + +add_task(async function test_deletionRequest_scalars() { + info("TelemetryFeed.init should set two scalars for deletion-request"); + + Services.telemetry.clearScalars(); + let instance = new TelemetryFeed(); + instance.init(); + + let snapshot = Services.telemetry.getSnapshotForScalars( + "deletion-request", + false + ).parent; + TelemetryTestUtils.assertScalar( + snapshot, + "deletion.request.impression_id", + instance._impressionId + ); + TelemetryTestUtils.assertScalar( + snapshot, + "deletion.request.context_id", + FAKE_UUID + ); + instance.uninit(); +}); + +add_task(async function test_metrics_on_initialization() { + info("TelemetryFeed.init should record initial metrics from newtab prefs"); + Services.fog.testResetFOG(); + const ENABLED_SETTING = true; + const TOP_SITES_ROWS = 3; + const BLOCKED_SPONSORS = ["mozilla"]; + + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.topsites", + ENABLED_SETTING + ); + Services.prefs.setIntPref( + "browser.newtabpage.activity-stream.topSitesRows", + TOP_SITES_ROWS + ); + Services.prefs.setCharPref( + "browser.topsites.blockedSponsors", + JSON.stringify(BLOCKED_SPONSORS) + ); + + let instance = new TelemetryFeed(); + instance.init(); + + Assert.equal(Glean.topsites.enabled.testGetValue(), ENABLED_SETTING); + Assert.equal(Glean.topsites.rows.testGetValue(), TOP_SITES_ROWS); + Assert.deepEqual( + Glean.newtab.blockedSponsors.testGetValue(), + BLOCKED_SPONSORS + ); + + instance.uninit(); + + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.feeds.topsites" + ); + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.topSitesRows" + ); + Services.prefs.clearUserPref("browser.topsites.blockedSponsors"); +}); + +add_task(async function test_metrics_with_bad_json() { + info( + "TelemetryFeed.init should not record blocked sponsor metrics when " + + "bad json string is passed" + ); + Services.fog.testResetFOG(); + Services.prefs.setCharPref("browser.topsites.blockedSponsors", "BAD[JSON]"); + + let instance = new TelemetryFeed(); + instance.init(); + + Assert.equal(Glean.newtab.blockedSponsors.testGetValue(), null); + + instance.uninit(); + + Services.prefs.clearUserPref("browser.topsites.blockedSponsors"); +}); + +add_task(async function test_metrics_on_pref_changes() { + info("TelemetryFeed.init should record new metrics for newtab pref changes"); + const INITIAL_TOP_SITES_ROWS = 3; + const INITIAL_BLOCKED_SPONSORS = []; + Services.fog.testResetFOG(); + Services.prefs.setIntPref( + "browser.newtabpage.activity-stream.topSitesRows", + INITIAL_TOP_SITES_ROWS + ); + Services.prefs.setCharPref( + "browser.topsites.blockedSponsors", + JSON.stringify(INITIAL_BLOCKED_SPONSORS) + ); + + let instance = new TelemetryFeed(); + instance.init(); + + Assert.equal(Glean.topsites.rows.testGetValue(), INITIAL_TOP_SITES_ROWS); + Assert.deepEqual( + Glean.newtab.blockedSponsors.testGetValue(), + INITIAL_BLOCKED_SPONSORS + ); + + const NEXT_TOP_SITES_ROWS = 2; + const NEXT_BLOCKED_SPONSORS = ["mozilla"]; + + Services.prefs.setIntPref( + "browser.newtabpage.activity-stream.topSitesRows", + NEXT_TOP_SITES_ROWS + ); + + Services.prefs.setStringPref( + "browser.topsites.blockedSponsors", + JSON.stringify(NEXT_BLOCKED_SPONSORS) + ); + + Assert.equal(Glean.topsites.rows.testGetValue(), NEXT_TOP_SITES_ROWS); + Assert.deepEqual( + Glean.newtab.blockedSponsors.testGetValue(), + NEXT_BLOCKED_SPONSORS + ); + + instance.uninit(); + + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.topSitesRows" + ); + Services.prefs.clearUserPref("browser.topsites.blockedSponsors"); +}); + +add_task(async function test_events_on_pref_changes() { + info("TelemetryFeed.init should record events for some newtab pref changes"); + // We only record events for browser.newtabpage.activity-stream.feeds.topsites and + // browser.newtabpage.activity-stream.showSponsoredTopSites being changed. + const INITIAL_TOPSITES_ENABLED = false; + const INITIAL_SHOW_SPONSORED_TOP_SITES = true; + Services.fog.testResetFOG(); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.topsites", + INITIAL_TOPSITES_ENABLED + ); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.showSponsoredTopSites", + INITIAL_SHOW_SPONSORED_TOP_SITES + ); + + let instance = new TelemetryFeed(); + instance.init(); + + const NEXT_TOPSITES_ENABLED = true; + const NEXT_SHOW_SPONSORED_TOP_SITES = false; + + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.topsites", + NEXT_TOPSITES_ENABLED + ); + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.showSponsoredTopSites", + NEXT_SHOW_SPONSORED_TOP_SITES + ); + + let prefChangeEvents = Glean.topsites.prefChanged.testGetValue(); + Assert.deepEqual(prefChangeEvents[0].extra, { + pref_name: "browser.newtabpage.activity-stream.feeds.topsites", + new_value: String(NEXT_TOPSITES_ENABLED), + }); + Assert.deepEqual(prefChangeEvents[1].extra, { + pref_name: "browser.newtabpage.activity-stream.showSponsoredTopSites", + new_value: String(NEXT_SHOW_SPONSORED_TOP_SITES), + }); + + instance.uninit(); + + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.feeds.topsites" + ); + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.showSponsoredTopSites" + ); +}); + +add_task(async function test_browserOpenNewtabStart() { + info( + "TelemetryFeed.browserOpenNewtabStart should call " + + "ChromeUtils.addProfilerMarker with browser-open-newtab-start" + ); + + let instance = new TelemetryFeed(); + + let entries = 10000; + let interval = 1; + let threads = ["GeckoMain"]; + let features = []; + await Services.profiler.StartProfiler(entries, interval, features, threads); + instance.browserOpenNewtabStart(); + + let profileArrayBuffer = + await Services.profiler.getProfileDataAsArrayBuffer(); + await Services.profiler.StopProfiler(); + + let profileUint8Array = new Uint8Array(profileArrayBuffer); + let textDecoder = new TextDecoder("utf-8", { fatal: true }); + let profileString = textDecoder.decode(profileUint8Array); + let profile = JSON.parse(profileString); + Assert.ok(profile.threads); + Assert.equal(profile.threads.length, 1); + + let foundMarker = profile.threads[0].markers.data.find(marker => { + return marker[5]?.name === "browser-open-newtab-start"; + }); + + Assert.ok(foundMarker, "Found the browser-open-newtab-start marker"); +}); + +add_task(async function test_addSession_and_get_session() { + info("TelemetryFeed.addSession should add a session and return it"); + let instance = new TelemetryFeed(); + let session = instance.addSession("foo"); + + Assert.equal(instance.sessions.get("foo"), session); + + info("TelemetryFeed.addSession should set a session_id"); + Assert.ok(session.session_id, "Should have a session_id set"); +}); + +add_task(async function test_addSession_url_param() { + info("TelemetryFeed.addSession should set the page if a url param is given"); + let instance = new TelemetryFeed(); + let session = instance.addSession("foo", "about:monkeys"); + Assert.equal(session.page, "about:monkeys"); + + info( + "TelemetryFeed.assSession should set the page prop to 'unknown' " + + "if no URL param given" + ); + session = instance.addSession("test2"); + Assert.equal(session.page, "unknown"); +}); + +add_task(async function test_addSession_perf_properties() { + info( + "TelemetryFeed.addSession should set the perf type when lacking " + + "timestamp" + ); + let instance = new TelemetryFeed(); + let session = instance.addSession("foo"); + Assert.equal(session.perf.load_trigger_type, "unexpected"); + + info( + "TelemetryFeed.addSession should set load_trigger_type to " + + "first_window_opened on the first about:home seen" + ); + session = instance.addSession("test2", "about:home"); + Assert.equal(session.perf.load_trigger_type, "first_window_opened"); + + info( + "TelemetryFeed.addSession should set load_trigger_ts to the " + + "value of the process start timestamp" + ); + Assert.equal( + session.perf.load_trigger_ts, + Services.startup.getStartupInfo().process.getTime(), + "Should have set a timestamp to be the process start time" + ); + + info( + "TelemetryFeed.addSession should NOT set load_trigger_type to " + + "first_window_opened on the second about:home seen" + ); + let session2 = instance.addSession("test2", "about:home"); + Assert.notEqual(session2.perf.load_trigger_type, "first_window_opened"); +}); + +add_task(async function test_addSession_valid_ping_on_first_abouthome() { + info( + "TelemetryFeed.addSession should create a valid session ping " + + "on the first about:home seen" + ); + let instance = new TelemetryFeed(); + // Add a session + const PORT_ID = "foo"; + let session = instance.addSession(PORT_ID, "about:home"); + + // Create a ping referencing the session + let ping = instance.createSessionEndEvent(session); + await assertSessionPingValid(ping); +}); + +add_task(async function test_addSession_valid_ping_data_late_by_ms() { + info( + "TelemetryFeed.addSession should create a valid session ping " + + "with the data_late_by_ms perf" + ); + let instance = new TelemetryFeed(); + // Add a session + const PORT_ID = "foo"; + let session = instance.addSession(PORT_ID, "about:home"); + + const TOPSITES_LATE_BY_MS = 10; + const HIGHLIGHTS_LATE_BY_MS = 20; + instance.saveSessionPerfData("foo", { + topsites_data_late_by_ms: TOPSITES_LATE_BY_MS, + }); + instance.saveSessionPerfData("foo", { + highlights_data_late_by_ms: HIGHLIGHTS_LATE_BY_MS, + }); + + // Create a ping referencing the session + let ping = instance.createSessionEndEvent(session); + await assertSessionPingValid(ping); + Assert.equal(session.perf.topsites_data_late_by_ms, TOPSITES_LATE_BY_MS); + Assert.equal(session.perf.highlights_data_late_by_ms, HIGHLIGHTS_LATE_BY_MS); +}); + +add_task(async function test_addSession_valid_ping_topsites_stats_perf() { + info( + "TelemetryFeed.addSession should create a valid session ping " + + "with the topsites stats perf" + ); + let instance = new TelemetryFeed(); + // Add a session + const PORT_ID = "foo"; + let session = instance.addSession(PORT_ID, "about:home"); + + const SCREENSHOT_WITH_ICON = 2; + const TOPSITES_PINNED = 3; + const TOPSITES_SEARCH_SHORTCUTS = 2; + + instance.saveSessionPerfData("foo", { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot_with_icon: SCREENSHOT_WITH_ICON, + screenshot: 1, + tippytop: 2, + rich_icon: 1, + no_image: 0, + }, + topsites_pinned: TOPSITES_PINNED, + topsites_search_shortcuts: TOPSITES_SEARCH_SHORTCUTS, + }); + + // Create a ping referencing the session + let ping = instance.createSessionEndEvent(session); + await assertSessionPingValid(ping); + Assert.equal( + instance.sessions.get("foo").perf.topsites_icon_stats.screenshot_with_icon, + SCREENSHOT_WITH_ICON + ); + Assert.equal( + instance.sessions.get("foo").perf.topsites_pinned, + TOPSITES_PINNED + ); + Assert.equal( + instance.sessions.get("foo").perf.topsites_search_shortcuts, + TOPSITES_SEARCH_SHORTCUTS + ); +}); + +add_task(async function test_endSession_no_throw_on_bad_session() { + info( + "TelemetryFeed.endSession should not throw if there is no " + + "session for a given port ID" + ); + let instance = new TelemetryFeed(); + try { + instance.endSession("doesn't exist"); + Assert.ok(true, "Did not throw."); + } catch (e) { + Assert.ok(false, "Should not have thrown."); + } +}); + +add_task(async function test_endSession_session_duration() { + info( + "TelemetryFeed.endSession should add a session_duration integer " + + "if there is a visibility_event_rcvd_ts" + ); + let instance = new TelemetryFeed(); + let session = instance.addSession("foo"); + session.perf.visibility_event_rcvd_ts = 444.4732; + instance.endSession("foo"); + + Assert.ok( + Number.isInteger(session.session_duration), + "session_duration should be an integer" + ); +}); + +add_task(async function test_endSession_no_ping_on_no_visibility_event() { + info( + "TelemetryFeed.endSession shouldn't send session ping if there's " + + "no visibility_event_rcvd_ts" + ); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + let instance = new TelemetryFeed(); + instance.addSession("foo"); + + Services.telemetry.clearEvents(); + instance.endSession("foo"); + TelemetryTestUtils.assertNumberOfEvents(0); + + info("TelemetryFeed.endSession should remove the session from .sessions"); + Assert.ok(!instance.sessions.has("foo")); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); +}); + +add_task(async function test_endSession_send_ping() { + info( + "TelemetryFeed.endSession should call createSessionSendEvent with the " + + "session if visibilty_event_rcvd_ts was set" + ); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + let instance = new TelemetryFeed(); + + let sandbox = sinon.createSandbox(); + sandbox.stub(instance, "createSessionEndEvent"); + sandbox.stub(instance.utEvents, "sendSessionEndEvent"); + + let session = instance.addSession("foo"); + + session.perf.visibility_event_rcvd_ts = 444.4732; + instance.endSession("foo"); + + Assert.ok(instance.createSessionEndEvent.calledWith(session)); + let sessionEndEvent = instance.createSessionEndEvent.firstCall.returnValue; + Assert.ok(instance.utEvents.sendSessionEndEvent.calledWith(sessionEndEvent)); + + info("TelemetryFeed.endSession should remove the session from .sessions"); + Assert.ok(!instance.sessions.has("foo")); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + + sandbox.restore(); +}); + +add_task(async function test_createPing_valid_base_if_no_portID() { + info( + "TelemetryFeed.createPing should create a valid base ping " + + "without a session if no portID is supplied" + ); + let instance = new TelemetryFeed(); + let ping = await instance.createPing(); + await assertBasePingValid(ping); + Assert.ok(!ping.session_id); + Assert.ok(!ping.page); +}); + +add_task(async function test_createPing_valid_base_if_portID() { + info( + "TelemetryFeed.createPing should create a valid base ping " + + "with session info if a portID is supplied" + ); + // Add a session + const PORT_ID = "foo"; + let instance = new TelemetryFeed(); + instance.addSession(PORT_ID, "about:home"); + let sessionID = instance.sessions.get(PORT_ID).session_id; + + // Create a ping referencing the session + let ping = await instance.createPing(PORT_ID); + await assertBasePingValid(ping); + + // Make sure we added the right session-related stuff to the ping + Assert.equal(ping.session_id, sessionID); + Assert.equal(ping.page, "about:home"); +}); + +add_task(async function test_createPing_no_session_yet_portID() { + info( + "TelemetryFeed.createPing should create an 'unexpected' base ping " + + "if no session yet portID is supplied" + ); + let instance = new TelemetryFeed(); + let ping = await instance.createPing("foo"); + await assertBasePingValid(ping); + + Assert.equal(ping.page, "unknown"); + Assert.equal( + instance.sessions.get("foo").perf.load_trigger_type, + "unexpected" + ); +}); + +add_task(async function test_createPing_includes_userPrefs() { + info("TelemetryFeed.createPing should create a base ping with user_prefs"); + let expectedUserPrefs = 0; + + for (let pref of Object.keys(USER_PREFS_ENCODING)) { + Services.prefs.setBoolPref( + `browser.newtabpage.activity-stream.${pref}`, + true + ); + expectedUserPrefs |= USER_PREFS_ENCODING[pref]; + } + + let instance = new TelemetryFeed(); + let ping = await instance.createPing("foo"); + await assertBasePingValid(ping); + Assert.equal(ping.user_prefs, expectedUserPrefs); + + for (const pref of Object.keys(USER_PREFS_ENCODING)) { + Services.prefs.clearUserPref(`browser.newtabpage.activity-stream.${pref}`); + } +}); + +add_task(async function test_createUserEvent_is_valid() { + info( + "TelemetryFeed.createUserEvent should create a valid user event ping " + + "with the right session_id" + ); + const PORT_ID = "foo"; + + let instance = new TelemetryFeed(); + let data = { source: "TOP_SITES", event: "CLICK" }; + let action = ac.AlsoToMain(ac.UserEvent(data), PORT_ID); + let session = instance.addSession(PORT_ID); + + let ping = await instance.createUserEvent(action); + + // Is it valid? + await assertUserEventPingValid(ping); + // Does it have the right session_id? + Assert.equal(ping.session_id, session.session_id); +}); + +add_task(async function test_createSessionEndEvent_is_valid() { + info( + "TelemetryFeed.createSessionEndEvent should create a valid session ping" + ); + const FAKE_DURATION = 12345; + let instance = new TelemetryFeed(); + let ping = await instance.createSessionEndEvent({ + session_id: FAKE_UUID, + page: "about:newtab", + session_duration: FAKE_DURATION, + perf: { + load_trigger_ts: 10, + load_trigger_type: "menu_plus_or_keyboard", + visibility_event_rcvd_ts: 20, + is_preloaded: true, + }, + }); + + // Is it valid? + await assertSessionPingValid(ping); + Assert.equal(ping.session_id, FAKE_UUID); + Assert.equal(ping.page, "about:newtab"); + Assert.equal(ping.session_duration, FAKE_DURATION); +}); + +add_task(async function test_createSessionEndEvent_with_unexpected_is_valid() { + info( + "TelemetryFeed.createSessionEndEvent should create a valid 'unexpected' " + + "session ping" + ); + const FAKE_DURATION = 12345; + const FAKE_TRIGGER_TYPE = "unexpected"; + + let instance = new TelemetryFeed(); + let ping = await instance.createSessionEndEvent({ + session_id: FAKE_UUID, + page: "about:newtab", + session_duration: FAKE_DURATION, + perf: { + load_trigger_type: FAKE_TRIGGER_TYPE, + is_preloaded: true, + }, + }); + + // Is it valid? + await assertSessionPingValid(ping); + Assert.equal(ping.session_id, FAKE_UUID); + Assert.equal(ping.page, "about:newtab"); + Assert.equal(ping.session_duration, FAKE_DURATION); + Assert.equal(ping.perf.load_trigger_type, FAKE_TRIGGER_TYPE); +}); + +add_task(async function test_applyCFRPolicy_prerelease() { + info( + "TelemetryFeed.applyCFRPolicy should use client_id and message_id " + + "in prerelease" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("nightly"); + + let instance = new TelemetryFeed(); + + let data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + let { ping, pingType } = await instance.applyCFRPolicy(data); + + Assert.equal(pingType, "cfr"); + Assert.equal(ping.impression_id, undefined); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.bucket_id, "cfr_bucket_01"); + Assert.equal(ping.message_id, "cfr_message_01"); + + sandbox.restore(); +}); + +add_task(async function test_applyCFRPolicy_release() { + info( + "TelemetryFeed.applyCFRPolicy should use impression_id and bucket_id " + + "in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox + .stub(TelemetryFeed.prototype, "getOrCreateImpressionId") + .returns(FAKE_UUID); + + let instance = new TelemetryFeed(); + + let data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + let { ping, pingType } = await instance.applyCFRPolicy(data); + + Assert.equal(pingType, "cfr"); + Assert.equal(ping.impression_id, FAKE_UUID); + Assert.equal(ping.client_id, undefined); + Assert.equal(ping.bucket_id, "cfr_bucket_01"); + Assert.equal(ping.message_id, "n/a"); + + sandbox.restore(); +}); + +add_task(async function test_applyCFRPolicy_experiment_release() { + info( + "TelemetryFeed.applyCFRPolicy should use impression_id and bucket_id " + + "in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + + let instance = new TelemetryFeed(); + + let data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + let { ping, pingType } = await instance.applyCFRPolicy(data); + + Assert.equal(pingType, "cfr"); + Assert.equal(ping.impression_id, undefined); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.bucket_id, "cfr_bucket_01"); + Assert.equal(ping.message_id, "cfr_message_01"); + + sandbox.restore(); +}); + +add_task(async function test_applyCFRPolicy_release_private_browsing() { + info( + "TelemetryFeed.applyCFRPolicy should use impression_id and bucket_id " + + "in Private Browsing in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox + .stub(TelemetryFeed.prototype, "getOrCreateImpressionId") + .returns(FAKE_UUID); + + let instance = new TelemetryFeed(); + + let data = { + action: "cfr_user_event", + event: "IMPRESSION", + is_private: true, + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + let { ping, pingType } = await instance.applyCFRPolicy(data); + + Assert.equal(pingType, "cfr"); + Assert.equal(ping.impression_id, FAKE_UUID); + Assert.equal(ping.client_id, undefined); + Assert.equal(ping.bucket_id, "cfr_bucket_01"); + Assert.equal(ping.message_id, "n/a"); + + sandbox.restore(); +}); + +add_task( + async function test_applyCFRPolicy_release_experiment_private_browsing() { + info( + "TelemetryFeed.applyCFRPolicy should use client_id and message_id in the " + + "experiment cohort in Private Browsing in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + + let instance = new TelemetryFeed(); + + let data = { + action: "cfr_user_event", + event: "IMPRESSION", + is_private: true, + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + let { ping, pingType } = await instance.applyCFRPolicy(data); + + Assert.equal(pingType, "cfr"); + Assert.equal(ping.impression_id, undefined); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.bucket_id, "cfr_bucket_01"); + Assert.equal(ping.message_id, "cfr_message_01"); + + sandbox.restore(); + } +); + +add_task(async function test_applyWhatsNewPolicy() { + info( + "TelemetryFeed.applyWhatsNewPolicy should set client_id and set pingType" + ); + let instance = new TelemetryFeed(); + let { ping, pingType } = await instance.applyWhatsNewPolicy({}); + + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(pingType, "whats-new-panel"); +}); + +add_task(async function test_applyInfoBarPolicy() { + info( + "TelemetryFeed.applyInfoBarPolicy should set client_id and set pingType" + ); + let instance = new TelemetryFeed(); + let { ping, pingType } = await instance.applyInfoBarPolicy({}); + + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(pingType, "infobar"); +}); + +add_task(async function test_applyToastNotificationPolicy() { + info( + "TelemetryFeed.applyToastNotificationPolicy should set client_id " + + "and set pingType" + ); + let instance = new TelemetryFeed(); + let { ping, pingType } = await instance.applyToastNotificationPolicy({}); + + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(pingType, "toast_notification"); +}); + +add_task(async function test_applySpotlightPolicy() { + info( + "TelemetryFeed.applySpotlightPolicy should set client_id " + + "and set pingType" + ); + let instance = new TelemetryFeed(); + let { ping, pingType } = await instance.applySpotlightPolicy({ + action: "foo", + }); + + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(pingType, "spotlight"); + Assert.equal(ping.action, undefined); +}); + +add_task(async function test_applyMomentsPolicy_prerelease() { + info( + "TelemetryFeed.applyMomentsPolicy should use client_id and " + + "message_id in prerelease" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("nightly"); + + let instance = new TelemetryFeed(); + let data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + let { ping, pingType } = await instance.applyMomentsPolicy(data); + + Assert.equal(pingType, "moments"); + Assert.equal(ping.impression_id, undefined); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.bucket_id, "moments_bucket_01"); + Assert.equal(ping.message_id, "moments_message_01"); + + sandbox.restore(); +}); + +add_task(async function test_applyMomentsPolicy_release() { + info( + "TelemetryFeed.applyMomentsPolicy should use impression_id and " + + "bucket_id in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox + .stub(TelemetryFeed.prototype, "getOrCreateImpressionId") + .returns(FAKE_UUID); + + let instance = new TelemetryFeed(); + let data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + let { ping, pingType } = await instance.applyMomentsPolicy(data); + + Assert.equal(pingType, "moments"); + Assert.equal(ping.impression_id, FAKE_UUID); + Assert.equal(ping.client_id, undefined); + Assert.equal(ping.bucket_id, "moments_bucket_01"); + Assert.equal(ping.message_id, "n/a"); + + sandbox.restore(); +}); + +add_task(async function test_applyMomentsPolicy_experiment_release() { + info( + "TelemetryFeed.applyMomentsPolicy client_id and message_id in " + + "the experiment cohort in release" + ); + let sandbox = sinon.createSandbox(); + sandbox.stub(UpdateUtils, "getUpdateChannel").returns("release"); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + + let instance = new TelemetryFeed(); + let data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + let { ping, pingType } = await instance.applyMomentsPolicy(data); + + Assert.equal(pingType, "moments"); + Assert.equal(ping.impression_id, undefined); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.bucket_id, "moments_bucket_01"); + Assert.equal(ping.message_id, "moments_message_01"); + + sandbox.restore(); +}); + +add_task(async function test_applyOnboardingPolicy_includes_client_id() { + info("TelemetryFeed.applyOnboardingPolicy should include client_id"); + let instance = new TelemetryFeed(); + let data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + let { ping, pingType } = await instance.applyOnboardingPolicy(data); + + Assert.equal(pingType, "onboarding"); + Assert.equal( + ping.client_id, + Services.prefs.getCharPref("toolkit.telemetry.cachedClientID") + ); + Assert.equal(ping.message_id, "onboarding_message_01"); + Assert.equal( + ping.browser_session_id, + TelemetrySession.getMetadata("").sessionId + ); +}); + +add_task(async function test_applyOnboardingPolicy_with_session() { + info( + "TelemetryFeed.applyOnboardingPolicy should include page to " + + "event_context if there is a session" + ); + let instance = new TelemetryFeed(); + let data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + let session = { page: "about:welcome" }; + let { ping, pingType } = await instance.applyOnboardingPolicy(data, session); + + Assert.equal(pingType, "onboarding"); + Assert.equal(ping.event_context, JSON.stringify({ page: "about:welcome" })); + Assert.equal(ping.message_id, "onboarding_message_01"); +}); + +add_task(async function test_applyOnboardingPolicy_only_allowed_pages() { + info( + "TelemetryFeed.applyOnboardingPolicy should not set page if it is " + + "not in ONBOARDING_ALLOWED_PAGE_VALUES" + ); + let instance = new TelemetryFeed(); + let data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + let session = { page: "foo" }; + let { ping, pingType } = await instance.applyOnboardingPolicy(data, session); + + Assert.equal(pingType, "onboarding"); + Assert.equal(ping.event_context, JSON.stringify({})); + Assert.equal(ping.message_id, "onboarding_message_01"); +}); + +add_task( + async function test_applyOnboardingPolicy_append_page_to_event_context() { + info( + "TelemetryFeed.applyOnboardingPolicy should append page to event_context " + + "if it is not empty" + ); + let instance = new TelemetryFeed(); + let data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + event_context: JSON.stringify({ foo: "bar" }), + }; + let session = { page: "about:welcome" }; + let { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + Assert.equal(pingType, "onboarding"); + Assert.equal( + ping.event_context, + JSON.stringify({ foo: "bar", page: "about:welcome" }) + ); + Assert.equal(ping.message_id, "onboarding_message_01"); + } +); + +add_task( + async function test_applyOnboardingPolicy_append_page_to_event_context() { + info( + "TelemetryFeed.applyOnboardingPolicy should append page to event_context " + + "if it is not a JSON serialized string" + ); + let instance = new TelemetryFeed(); + let data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + event_context: "foo", + }; + let session = { page: "about:welcome" }; + let { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + Assert.equal(pingType, "onboarding"); + Assert.equal( + ping.event_context, + JSON.stringify({ value: "foo", page: "about:welcome" }) + ); + Assert.equal(ping.message_id, "onboarding_message_01"); + } +); + +add_task(async function test_applyUndesiredEventPolicy() { + info( + "TelemetryFeed.applyUndesiredEventPolicy should exclude client_id " + + "and use impression_id" + ); + let sandbox = sinon.createSandbox(); + sandbox + .stub(TelemetryFeed.prototype, "getOrCreateImpressionId") + .returns(FAKE_UUID); + + let instance = new TelemetryFeed(); + let data = { + action: "asrouter_undesired_event", + event: "RS_MISSING_DATA", + }; + let { ping, pingType } = await instance.applyUndesiredEventPolicy(data); + + Assert.equal(pingType, "undesired-events"); + Assert.equal(ping.client_id, undefined); + Assert.equal(ping.impression_id, FAKE_UUID); + + sandbox.restore(); +}); + +add_task(async function test_createASRouterEvent_valid_ping() { + info( + "TelemetryFeed.createASRouterEvent should create a valid " + + "ASRouterEventPing ping" + ); + let instance = new TelemetryFeed(); + let data = { + action: "cfr_user_event", + event: "CLICK", + message_id: "cfr_message_01", + }; + let action = ac.ASRouterUserEvent(data); + let { ping } = await instance.createASRouterEvent(action); + + await assertASRouterEventPingValid(ping); + Assert.equal(ping.event, "CLICK"); +}); + +add_task(async function test_createASRouterEvent_call_correctPolicy() { + let testCallCorrectPolicy = async (expectedPolicyFnName, data) => { + info( + `TelemetryFeed.createASRouterEvent should call ${expectedPolicyFnName} ` + + `on action ${data.action} and event ${data.event}` + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + sandbox.stub(instance, expectedPolicyFnName); + + let action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + Assert.ok( + instance[expectedPolicyFnName].calledOnce, + `TelemetryFeed.${expectedPolicyFnName} called` + ); + + sandbox.restore(); + }; + + testCallCorrectPolicy("applyCFRPolicy", { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + }); + + testCallCorrectPolicy("applyOnboardingPolicy", { + action: "onboarding_user_event", + event: "CLICK_BUTTON", + message_id: "onboarding_message_01", + }); + + testCallCorrectPolicy("applyWhatsNewPolicy", { + action: "whats-new-panel_user_event", + event: "CLICK_BUTTON", + message_id: "whats-new-panel_message_01", + }); + + testCallCorrectPolicy("applyMomentsPolicy", { + action: "moments_user_event", + event: "CLICK_BUTTON", + message_id: "moments_message_01", + }); + + testCallCorrectPolicy("applySpotlightPolicy", { + action: "spotlight_user_event", + event: "CLICK", + message_id: "SPOTLIGHT_MESSAGE_93", + }); + + testCallCorrectPolicy("applyToastNotificationPolicy", { + action: "toast_notification_user_event", + event: "IMPRESSION", + message_id: "TEST_TOAST_NOTIFICATION1", + }); + + testCallCorrectPolicy("applyUndesiredEventPolicy", { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + }); +}); + +add_task(async function test_createASRouterEvent_stringify_event_context() { + info( + "TelemetryFeed.createASRouterEvent should stringify event_context if " + + "it is an Object" + ); + let instance = new TelemetryFeed(); + let data = { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + event_context: { foo: "bar" }, + }; + let action = ac.ASRouterUserEvent(data); + let { ping } = await instance.createASRouterEvent(action); + + Assert.equal(ping.event_context, JSON.stringify({ foo: "bar" })); +}); + +add_task(async function test_createASRouterEvent_not_stringify_event_context() { + info( + "TelemetryFeed.createASRouterEvent should not stringify event_context " + + "if it is a String" + ); + let instance = new TelemetryFeed(); + let data = { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + event_context: "foo", + }; + let action = ac.ASRouterUserEvent(data); + let { ping } = await instance.createASRouterEvent(action); + + Assert.equal(ping.event_context, "foo"); +}); + +add_task(async function test_sendUTEvent_call_right_function() { + info("TelemetryFeed.sendUTEvent should call the UT event function passed in"); + let sandbox = sinon.createSandbox(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + + let event = {}; + let instance = new TelemetryFeed(); + sandbox.stub(instance.utEvents, "sendUserEvent"); + instance.addSession("foo"); + + await instance.sendUTEvent(event, instance.utEvents.sendUserEvent); + Assert.ok(instance.utEvents.sendUserEvent.calledWith(event)); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_setLoadTriggerInfo() { + info( + "TelemetryFeed.setLoadTriggerInfo should call saveSessionPerfData " + + "w/load_trigger_{ts,type} data" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + sandbox.stub(instance, "saveSessionPerfData"); + + instance.browserOpenNewtabStart(); + instance.addSession("port123"); + instance.setLoadTriggerInfo("port123"); + + Assert.ok( + instance.saveSessionPerfData.calledWith( + "port123", + sinon.match({ + load_trigger_type: "menu_plus_or_keyboard", + load_trigger_ts: sinon.match.number, + }) + ), + "TelemetryFeed.saveSessionPerfData was called with the right arguments" + ); + + sandbox.restore(); +}); + +add_task(async function test_setLoadTriggerInfo_no_saveSessionPerfData() { + info( + "TelemetryFeed.setLoadTriggerInfo should not call saveSessionPerfData " + + "when getting mark throws" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + sandbox.stub(instance, "saveSessionPerfData"); + + instance.addSession("port123"); + instance.setLoadTriggerInfo("port123"); + + Assert.ok( + instance.saveSessionPerfData.notCalled, + "TelemetryFeed.saveSessionPerfData was not called" + ); + + sandbox.restore(); +}); + +add_task(async function test_saveSessionPerfData_updates_session_with_data() { + info( + "TelemetryFeed.saveSessionPerfData should update the given session " + + "with the given data" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + instance.addSession("port123"); + Assert.equal(instance.sessions.get("port123").fake_ts, undefined); + let data = { fake_ts: 456, other_fake_ts: 789 }; + instance.saveSessionPerfData("port123", data); + + let sessionPerfData = instance.sessions.get("port123").perf; + Assert.equal(sessionPerfData.fake_ts, 456); + Assert.equal(sessionPerfData.other_fake_ts, 789); + + sandbox.restore(); +}); + +add_task(async function test_saveSessionPerfData_calls_setLoadTriggerInfo() { + info( + "TelemetryFeed.saveSessionPerfData should call setLoadTriggerInfo if " + + "data has visibility_event_rcvd_ts" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123"); + let data = { visibility_event_rcvd_ts: 444455 }; + + instance.saveSessionPerfData("port123", data); + + Assert.ok( + instance.setLoadTriggerInfo.calledOnce, + "TelemetryFeed.setLoadTriggerInfo was called once" + ); + Assert.ok(instance.setLoadTriggerInfo.calledWithExactly("port123")); + + Assert.equal( + instance.sessions.get("port123").perf.visibility_event_rcvd_ts, + 444455 + ); + + sandbox.restore(); +}); + +add_task( + async function test_saveSessionPerfData_does_not_call_setLoadTriggerInfo() { + info( + "TelemetryFeed.saveSessionPerfData shouldn't call setLoadTriggerInfo if " + + "data has no visibility_event_rcvd_ts" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123"); + instance.saveSessionPerfData("port123", { monkeys_ts: 444455 }); + + Assert.ok( + instance.setLoadTriggerInfo.notCalled, + "TelemetryFeed.setLoadTriggerInfo was not called" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_saveSessionPerfData_does_not_call_setLoadTriggerInfo_about_home() { + info( + "TelemetryFeed.saveSessionPerfData should not call setLoadTriggerInfo when " + + "url is about:home" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123", "about:home"); + let data = { visibility_event_rcvd_ts: 444455 }; + instance.saveSessionPerfData("port123", data); + + Assert.ok( + instance.setLoadTriggerInfo.notCalled, + "TelemetryFeed.setLoadTriggerInfo was not called" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_saveSessionPerfData_calls_maybeRecordTopsitesPainted() { + info( + "TelemetryFeed.saveSessionPerfData should call maybeRecordTopsitesPainted " + + "when url is about:home and topsites_first_painted_ts is given" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + const TOPSITES_FIRST_PAINTED_TS = 44455; + let data = { topsites_first_painted_ts: TOPSITES_FIRST_PAINTED_TS }; + + sandbox.stub(AboutNewTab, "maybeRecordTopsitesPainted"); + instance.addSession("port123", "about:home"); + instance.saveSessionPerfData("port123", data); + + Assert.ok( + AboutNewTab.maybeRecordTopsitesPainted.calledOnce, + "AboutNewTab.maybeRecordTopsitesPainted called once" + ); + Assert.ok( + AboutNewTab.maybeRecordTopsitesPainted.calledWith( + TOPSITES_FIRST_PAINTED_TS + ) + ); + sandbox.restore(); + } +); + +add_task( + async function test_saveSessionPerfData_records_Glean_newtab_opened_event() { + info( + "TelemetryFeed.saveSessionPerfData should record a Glean newtab.opened event " + + "with the correct visit_id when visibility event received" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "decafc0ffee"; + const PAGE = "about:newtab"; + let session = { page: PAGE, perf: {}, session_id: SESSION_ID }; + let data = { visibility_event_rcvd_ts: 444455 }; + + sandbox.stub(instance.sessions, "get").returns(session); + instance.saveSessionPerfData("port123", data); + + let newtabOpenedEvents = Glean.newtab.opened.testGetValue(); + Assert.deepEqual(newtabOpenedEvents[0].extra, { + newtab_visit_id: SESSION_ID, + source: PAGE, + }); + + sandbox.restore(); + } +); + +add_task(async function test_uninit_calls_utEvents_uninit() { + info("TelemetryFeed.uninit should call .utEvents.uninit"); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + sandbox.stub(instance.utEvents, "uninit"); + + instance.uninit(); + Assert.ok( + instance.utEvents.uninit.calledOnce, + "TelemetryFeed.utEvents.uninit should be called" + ); + sandbox.restore(); +}); + +add_task(async function test_uninit_deregisters_observer() { + info( + "TelemetryFeed.uninit should make this.browserOpenNewtabStart() stop " + + "observing browser-open-newtab-start" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + let countObservers = () => { + return [...Services.obs.enumerateObservers("browser-open-newtab-start")] + .length; + }; + + const ORIGINAL_COUNT = countObservers(); + instance.init(); + Assert.equal(countObservers(), ORIGINAL_COUNT + 1, "Observer was added"); + + instance.uninit(); + Assert.equal(countObservers(), ORIGINAL_COUNT, "Observer was removed"); + + sandbox.restore(); +}); + +add_task(async function test_onAction_basic_actions() { + let browser = Services.appShell + .createWindowlessBrowser(false) + .document.createElement("browser"); + + let testOnAction = (setupFn, action, checkFn) => { + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + setupFn(sandbox, instance); + + instance.onAction(action); + checkFn(instance); + sandbox.restore(); + }; + + info("TelemetryFeed.onAction should call .init() on an INIT action"); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "init"); + sandbox.stub(instance, "sendPageTakeoverData"); + }, + { type: at.INIT }, + instance => { + Assert.ok(instance.init.calledOnce, "TelemetryFeed.init called once"); + Assert.ok( + instance.sendPageTakeoverData.calledOnce, + "TelemetryFeed.sendPageTakeoverData called once" + ); + } + ); + + info("TelemetryFeed.onAction should call .uninit() on an UNINIT action"); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "uninit"); + }, + { type: at.UNINIT }, + instance => { + Assert.ok(instance.uninit.calledOnce, "TelemetryFeed.uninit called once"); + } + ); + + info( + "TelemetryFeed.onAction should call .handleNewTabInit on a " + + "NEW_TAB_INIT action" + ); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "handleNewTabInit"); + }, + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser }, + }), + instance => { + Assert.ok( + instance.handleNewTabInit.calledOnce, + "TelemetryFeed.handleNewTabInit called once" + ); + } + ); + + info( + "TelemetryFeed.onAction should call .addSession() on a " + + "NEW_TAB_INIT action" + ); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "addSession").returns({ perf: {} }); + sandbox.stub(instance, "setLoadTriggerInfo"); + }, + ac.AlsoToMain( + { + type: at.NEW_TAB_INIT, + data: { url: "about:monkeys", browser }, + }, + "port123" + ), + instance => { + Assert.ok( + instance.addSession.calledOnce, + "TelemetryFeed.addSession called once" + ); + Assert.ok(instance.addSession.calledWith("port123", "about:monkeys")); + } + ); + + info( + "TelemetryFeed.onAction should call .endSession() on a " + + "NEW_TAB_UNLOAD action" + ); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "endSession"); + }, + ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "port123"), + instance => { + Assert.ok( + instance.endSession.calledOnce, + "TelemetryFeed.endSession called once" + ); + Assert.ok(instance.endSession.calledWith("port123")); + } + ); + + info( + "TelemetryFeed.onAction should call .saveSessionPerfData " + + "on SAVE_SESSION_PERF_DATA" + ); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "saveSessionPerfData"); + }, + ac.AlsoToMain( + { type: at.SAVE_SESSION_PERF_DATA, data: { some_ts: 10 } }, + "port123" + ), + instance => { + Assert.ok( + instance.saveSessionPerfData.calledOnce, + "TelemetryFeed.saveSessionPerfData called once" + ); + Assert.ok( + instance.saveSessionPerfData.calledWith("port123", { some_ts: 10 }) + ); + } + ); + + info( + "TelemetryFeed.onAction should send an event on a TELEMETRY_USER_EVENT " + + "action" + ); + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "createUserEvent"); + sandbox.stub(instance.utEvents, "sendUserEvent"); + }, + { type: at.TELEMETRY_USER_EVENT }, + instance => { + Assert.ok( + instance.createUserEvent.calledOnce, + "TelemetryFeed.createUserEvent called once" + ); + Assert.ok( + instance.createUserEvent.calledWith({ type: at.TELEMETRY_USER_EVENT }) + ); + Assert.ok( + instance.utEvents.sendUserEvent.calledOnce, + "TelemetryFeed.utEvents.sendUserEvent called once" + ); + Assert.ok( + instance.utEvents.sendUserEvent.calledWith( + instance.createUserEvent.returnValue + ) + ); + } + ); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + Services.prefs.clearUserPref(PREF_TELEMETRY); + + info( + "TelemetryFeed.onAction should send an event on a " + + "DISCOVERY_STREAM_USER_EVENT action" + ); + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + testOnAction( + (sandbox, instance) => { + sandbox.stub(instance, "createUserEvent"); + sandbox.stub(instance.utEvents, "sendUserEvent"); + }, + { type: at.DISCOVERY_STREAM_USER_EVENT }, + instance => { + Assert.ok( + instance.createUserEvent.calledOnce, + "TelemetryFeed.createUserEvent called once" + ); + Assert.ok( + instance.createUserEvent.calledWith({ + type: at.DISCOVERY_STREAM_USER_EVENT, + data: { + value: { + pocket_logged_in_status: Glean.pocket.isSignedIn.testGetValue(), + }, + }, + }) + ); + Assert.ok( + instance.utEvents.sendUserEvent.calledOnce, + "TelemetryFeed.utEvents.sendUserEvent called once" + ); + Assert.ok( + instance.utEvents.sendUserEvent.calledWith( + instance.createUserEvent.returnValue + ) + ); + } + ); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + Services.prefs.clearUserPref(PREF_TELEMETRY); +}); + +add_task(async function test_onAction_calls_handleASRouterUserEvent() { + let actions = [ + at.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ]; + Services.prefs.setBoolPref(PREF_EVENT_TELEMETRY, true); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + actions.forEach(type => { + info(`Testing ${type} action`); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + const eventHandler = sandbox.spy(instance, "handleASRouterUserEvent"); + const action = { + type, + data: { event: "CLICK" }, + }; + + instance.onAction(action); + + Assert.ok(eventHandler.calledWith(action)); + sandbox.restore(); + }); + Services.prefs.clearUserPref(PREF_EVENT_TELEMETRY); + Services.prefs.clearUserPref(PREF_TELEMETRY); +}); + +add_task( + async function test_onAction_calls_handleDiscoveryStreamImpressionStats_ds() { + info( + "TelemetryFeed.onAction should call " + + ".handleDiscoveryStreamImpressionStats on a " + + "DISCOVERY_STREAM_IMPRESSION_STATS action" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + let data = { source: "foo", tiles: [{ id: 1 }] }; + let action = { type: at.DISCOVERY_STREAM_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleDiscoveryStreamImpressionStats"); + + instance.onAction(ac.AlsoToMain(action, "port123")); + + Assert.ok( + instance.handleDiscoveryStreamImpressionStats.calledWith("port123", data) + ); + + sandbox.restore(); + } +); + +add_task( + async function test_onAction_calls_handleTopSitesSponsoredImpressionStats() { + info( + "TelemetryFeed.onAction should call " + + ".handleTopSitesSponsoredImpressionStats on a " + + "TOP_SITES_SPONSORED_IMPRESSION_STATS action" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + let data = { type: "impression", tile_id: 42, position: 1 }; + let action = { type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleTopSitesSponsoredImpressionStats"); + + instance.onAction(ac.AlsoToMain(action)); + + Assert.ok( + instance.handleTopSitesSponsoredImpressionStats.calledOnce, + "TelemetryFeed.handleTopSitesSponsoredImpressionStats called once" + ); + Assert.deepEqual( + instance.handleTopSitesSponsoredImpressionStats.firstCall.args[0].data, + data + ); + + sandbox.restore(); + } +); + +add_task(async function test_onAction_calls_handleAboutSponsoredTopSites() { + info( + "TelemetryFeed.onAction should call " + + ".handleAboutSponsoredTopSites on a " + + "ABOUT_SPONSORED_TOP_SITES action" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let data = { position: 0, advertiser_name: "moo", tile_id: 42 }; + let action = { type: at.ABOUT_SPONSORED_TOP_SITES, data }; + sandbox.spy(instance, "handleAboutSponsoredTopSites"); + + instance.onAction(ac.AlsoToMain(action)); + + Assert.ok( + instance.handleAboutSponsoredTopSites.calledOnce, + "TelemetryFeed.handleAboutSponsoredTopSites called once" + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_calls_handleBlockUrl() { + info( + "TelemetryFeed.onAction should call #handleBlockUrl on a BLOCK_URL action" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let data = { position: 0, advertiser_name: "moo", tile_id: 42 }; + let action = { type: at.BLOCK_URL, data }; + sandbox.spy(instance, "handleBlockUrl"); + + instance.onAction(ac.AlsoToMain(action)); + + Assert.ok( + instance.handleBlockUrl.calledOnce, + "TelemetryFeed.handleBlockUrl called once" + ); + + sandbox.restore(); +}); + +add_task( + async function test_onAction_calls_handleTopSitesOrganicImpressionStats() { + info( + "TelemetryFeed.onAction should call .handleTopSitesOrganicImpressionStats " + + "on a TOP_SITES_ORGANIC_IMPRESSION_STATS action" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + + let data = { type: "impression", position: 1 }; + let action = { type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleTopSitesOrganicImpressionStats"); + + instance.onAction(ac.AlsoToMain(action)); + + Assert.ok( + instance.handleTopSitesOrganicImpressionStats.calledOnce, + "TelemetryFeed.handleTopSitesOrganicImpressionStats called once" + ); + Assert.deepEqual( + instance.handleTopSitesOrganicImpressionStats.firstCall.args[0].data, + data + ); + + sandbox.restore(); + } +); + +add_task(async function test_handleNewTabInit_sets_preloaded_session() { + info( + "TelemetryFeed.handleNewTabInit should set the session as preloaded " + + "if the browser is preloaded" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let session = { perf: {} }; + let preloadedBrowser = { + getAttribute() { + return "preloaded"; + }, + }; + sandbox.stub(instance, "addSession").returns(session); + + instance.onAction( + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser: preloadedBrowser }, + }) + ); + + Assert.ok(session.perf.is_preloaded, "is_preloaded property was set"); + + sandbox.restore(); +}); + +add_task(async function test_handleNewTabInit_sets_nonpreloaded_session() { + info( + "TelemetryFeed.handleNewTabInit should set the session as non-preloaded " + + "if the browser is non-preloaded" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + let session = { perf: {} }; + let preloadedBrowser = { + getAttribute() { + return ""; + }, + }; + sandbox.stub(instance, "addSession").returns(session); + + instance.onAction( + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser: preloadedBrowser }, + }) + ); + + Assert.ok(!session.perf.is_preloaded, "is_preloaded property is not true"); + + sandbox.restore(); +}); + +add_task( + async function test_SendASRouterUndesiredEvent_calls_handleASRouterUserEvent() { + info( + "TelemetryFeed.SendASRouterUndesiredEvent should call " + + "handleASRouterUserEvent" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + sandbox.stub(instance, "handleASRouterUserEvent"); + + instance.SendASRouterUndesiredEvent({ foo: "bar" }); + + Assert.ok( + instance.handleASRouterUserEvent.calledOnce, + "TelemetryFeed.handleASRouterUserEvent was called once" + ); + let [payload] = instance.handleASRouterUserEvent.firstCall.args; + Assert.equal(payload.data.action, "asrouter_undesired_event"); + Assert.equal(payload.data.foo, "bar"); + + sandbox.restore(); + } +); + +add_task(async function test_sendPageTakeoverData_homepage_category() { + info( + "TelemetryFeed.sendPageTakeoverData should call " + + "handleASRouterUserEvent" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + sandbox.stub(HomePage, "get").returns("https://searchprovider.com"); + instance._classifySite = () => Promise.resolve("other"); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.homepageCategory.testGetValue(), "other"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_newtab_category_custom() { + info( + "TelemetryFeed.sendPageTakeoverData should send correct newtab " + + "category for about:newtab set to custom URL" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + sandbox.stub(AboutNewTab, "newTabURLOverridden").get(() => true); + sandbox + .stub(AboutNewTab, "newTabURL") + .get(() => "https://searchprovider.com"); + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + instance._classifySite = () => Promise.resolve("other"); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.newtabCategory.testGetValue(), "other"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_newtab_category_custom() { + info( + "TelemetryFeed.sendPageTakeoverData should not set home|newtab " + + "category if neither about:{home,newtab} are set to custom URL" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + instance._classifySite = () => Promise.resolve("other"); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.newtabCategory.testGetValue(), "enabled"); + Assert.equal(Glean.newtab.homepageCategory.testGetValue(), "enabled"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_newtab_category_extension() { + info( + "TelemetryFeed.sendPageTakeoverData should set correct home|newtab " + + "category when changed by extension" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const ID = "{abc-foo-bar}"; + sandbox.stub(ExtensionSettingsStore, "getSetting").returns({ id: ID }); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + instance._classifySite = () => Promise.resolve("other"); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.newtabCategory.testGetValue(), "extension"); + Assert.equal(Glean.newtab.homepageCategory.testGetValue(), "extension"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_newtab_disabled() { + info( + "TelemetryFeed.sendPageTakeoverData instruments when newtab is disabled" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + Services.prefs.setBoolPref("browser.newtabpage.enabled", false); + instance._classifySite = () => Promise.resolve("other"); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.newtabCategory.testGetValue(), "disabled"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + Services.prefs.clearUserPref("browser.newtabpage.enabled"); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_homepage_disabled() { + info( + "TelemetryFeed.sendPageTakeoverData instruments when homepage is disabled" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + sandbox.stub(HomePage, "overridden").get(() => true); + + await instance.sendPageTakeoverData(); + Assert.equal(Glean.newtab.homepageCategory.testGetValue(), "disabled"); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task(async function test_sendPageTakeoverData_newtab_ping() { + info("TelemetryFeed.sendPageTakeoverData should send a 'newtab' ping"); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + + let pingSubmitted = new Promise(resolve => { + GleanPings.newtab.testBeforeNextSubmit(reason => { + Assert.equal(reason, "component_init"); + resolve(); + }); + }); + + await instance.sendPageTakeoverData(); + await pingSubmitted; + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); +}); + +add_task( + async function test_handleDiscoveryStreamImpressionStats_should_throw() { + info( + "TelemetryFeed.handleDiscoveryStreamImpressionStats should throw " + + "for a missing session" + ); + + let instance = new TelemetryFeed(); + try { + instance.handleDiscoveryStreamImpressionStats("a_missing_port", {}); + Assert.ok(false, "Should not have reached here."); + } catch (e) { + Assert.ok(true, "Should have thrown for a missing session."); + } + } +); + +add_task( + async function test_handleDiscoveryStreamImpressionStats_instrument_pocket_impressions() { + info( + "TelemetryFeed.handleDiscoveryStreamImpressionStats should throw " + + "for a missing session" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "1337cafe"; + const POS_1 = 1; + const POS_2 = 4; + const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + let pingSubmitted = new Promise(resolve => { + GleanPings.spoc.testBeforeNextSubmit(reason => { + Assert.equal(reason, "impression"); + + let pocketImpressions = Glean.pocket.impression.testGetValue(); + Assert.equal(pocketImpressions.length, 2); + Assert.deepEqual(pocketImpressions[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: String(POS_1), + recommendation_id: "decaf-c0ff33", + tile_id: String(1), + }); + Assert.deepEqual(pocketImpressions[1].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(true), + position: String(POS_2), + tile_id: String(2), + }); + Assert.equal(Glean.pocket.shim.testGetValue(), SHIM); + + resolve(); + }); + }); + + instance.handleDiscoveryStreamImpressionStats("_", { + source: "foo", + tiles: [ + { + id: 1, + pos: POS_1, + type: "organic", + recommendation_id: "decaf-c0ff33", + }, + { + id: 2, + pos: POS_2, + type: "spoc", + recommendation_id: undefined, + shim: SHIM, + }, + ], + window_inner_width: 1000, + window_inner_height: 900, + }); + + await pingSubmitted; + + sandbox.restore(); + } +); + +add_task( + async function test_handleASRouterUserEvent_calls_submitGleanPingForPing() { + info( + "TelemetryFeed.handleASRouterUserEvent should call " + + "submitGleanPingForPing on known pingTypes when telemetry is enabled" + ); + + let data = { + action: "onboarding_user_event", + event: "IMPRESSION", + message_id: "12345", + }; + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + + sandbox.spy(AboutWelcomeTelemetry.prototype, "submitGleanPingForPing"); + + await instance.handleASRouterUserEvent({ data }); + + Assert.ok( + AboutWelcomeTelemetry.prototype.submitGleanPingForPing.calledOnce, + "AboutWelcomeTelemetry.submitGleanPingForPing called once" + ); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); + } +); + +add_task( + async function test_handleASRouterUserEvent_no_submit_unknown_pingTypes() { + info( + "TelemetryFeed.handleASRouterUserEvent not submit pings on unknown pingTypes" + ); + + let data = { + action: "unknown_event", + event: "IMPRESSION", + message_id: "12345", + }; + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + Services.prefs.setBoolPref(PREF_TELEMETRY, true); + + sandbox.spy(AboutWelcomeTelemetry.prototype, "submitGleanPingForPing"); + + await instance.handleASRouterUserEvent({ data }); + + Assert.ok( + AboutWelcomeTelemetry.prototype.submitGleanPingForPing.notCalled, + "AboutWelcomeTelemetry.submitGleanPingForPing not called" + ); + + Services.prefs.clearUserPref(PREF_TELEMETRY); + sandbox.restore(); + } +); + +add_task( + async function test_isInCFRCohort_return_false_for_no_CFR_experiment() { + info( + "TelemetryFeed.isInCFRCohort should return false if there " + + "is no CFR experiment registered" + ); + let instance = new TelemetryFeed(); + Assert.ok( + !instance.isInCFRCohort, + "Should not be in CFR cohort by default" + ); + } +); + +add_task( + async function test_isInCFRCohort_return_true_for_registered_CFR_experiment() { + info( + "TelemetryFeed.isInCFRCohort should return true if there " + + "is a CFR experiment registered" + ); + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + + Assert.ok(instance.isInCFRCohort, "Should be in a CFR cohort"); + Assert.equal( + ExperimentAPI.getExperimentMetaData.firstCall.args[0].featureId, + "cfr" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesSponsoredImpressionStats_add_keyed_scalar() { + info( + "TelemetryFeed.handleTopSitesSponsoredImpressionStats should add to " + + "keyed scalar on an impression event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.telemetry.clearScalars(); + + let data = { + type: "impression", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + await instance.handleTopSitesSponsoredImpressionStats({ data }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "contextual.services.topsites.impression", + "newtab_1", + 1 + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesSponsoredImpressionStats_add_keyed_scalar_click() { + info( + "TelemetryFeed.handleTopSitesSponsoredImpressionStats should add to " + + "keyed scalar on a click event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.telemetry.clearScalars(); + + let data = { + type: "click", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + await instance.handleTopSitesSponsoredImpressionStats({ data }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "contextual.services.topsites.click", + "newtab_1", + 1 + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesSponsoredImpressionStats_record_glean_impression() { + info( + "TelemetryFeed.handleTopSitesSponsoredImpressionStats should record a " + + "Glean topsites.impression event on an impression event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + type: "impression", + tile_id: 42, + source: "newtab", + position: 1, + reporting_url: "https://test.reporting.net/", + advertiser: "adnoid ads", + }; + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + await instance.handleTopSitesSponsoredImpressionStats({ data }); + let impressions = Glean.topsites.impression.testGetValue(); + Assert.equal(impressions.length, 1, "Should have recorded 1 impression"); + + Assert.deepEqual(impressions[0].extra, { + advertiser_name: "adnoid ads", + tile_id: data.tile_id, + newtab_visit_id: SESSION_ID, + is_sponsored: String(true), + position: String(1), + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesSponsoredImpressionStats_record_glean_click() { + info( + "TelemetryFeed.handleTopSitesSponsoredImpressionStats should record " + + "a Glean topsites.click event on a click event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + type: "click", + advertiser: "test advertiser", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + await instance.handleTopSitesSponsoredImpressionStats({ data }); + let clicks = Glean.topsites.click.testGetValue(); + Assert.equal(clicks.length, 1, "Should have recorded 1 click"); + + Assert.deepEqual(clicks[0].extra, { + advertiser_name: "test advertiser", + tile_id: data.tile_id, + newtab_visit_id: SESSION_ID, + is_sponsored: String(true), + position: String(0), + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesSponsoredImpressionStats_no_submit_unknown_pingType() { + info( + "TelemetryFeed.handleTopSitesSponsoredImpressionStats should not " + + "submit on unknown pingTypes" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { type: "unknown_type" }; + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + let impressions = Glean.topsites.impression.testGetValue(); + Assert.ok(!impressions, "Should not have recorded any impressions"); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesOrganicImpressionStats_record_glean_topsites_impression() { + info( + "TelemetryFeed.handleTopSitesOrganicImpressionStats should record a " + + "Glean topsites.impression event on an impression event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + type: "impression", + source: "newtab", + position: 0, + }; + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + await instance.handleTopSitesOrganicImpressionStats({ data }); + let impressions = Glean.topsites.impression.testGetValue(); + Assert.equal(impressions.length, 1, "Recorded 1 impression"); + + Assert.deepEqual(impressions[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: String(0), + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesOrganicImpressionStats_record_glean_topsites_click() { + info( + "TelemetryFeed.handleTopSitesOrganicImpressionStats should record a " + + "Glean topsites.click event on a click event" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + type: "click", + source: "newtab", + position: 0, + }; + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + await instance.handleTopSitesOrganicImpressionStats({ data }); + let clicks = Glean.topsites.click.testGetValue(); + Assert.equal(clicks.length, 1, "Recorded 1 click"); + + Assert.deepEqual(clicks[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: String(0), + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesOrganicImpressionStats_no_recording() { + info( + "TelemetryFeed.handleTopSitesOrganicImpressionStats should not " + + "record events on an unknown session" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + sandbox.stub(instance.sessions, "get").returns(false); + + await instance.handleTopSitesOrganicImpressionStats({}); + Assert.ok(!Glean.topsites.click.testGetValue(), "Click was not recorded"); + Assert.ok( + !Glean.topsites.impression.testGetValue(), + "Impression was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleTopSitesOrganicImpressionStats_no_recording_with_session() { + info( + "TelemetryFeed.handleTopSitesOrganicImpressionStats should not record " + + "events on an unknown impressionStats action" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + await instance.handleTopSitesOrganicImpressionStats({ type: "unknown" }); + Assert.ok(!Glean.topsites.click.testGetValue(), "Click was not recorded"); + Assert.ok( + !Glean.topsites.impression.testGetValue(), + "Impression was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_no_recording_with_session() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent correctly handles " + + "action with no `data`" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let action = ac.DiscoveryStreamUserEvent(); + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + Assert.ok( + !Glean.pocket.topicClick.testGetValue(), + "Pocket topicClick was not recorded" + ); + Assert.ok( + !Glean.pocket.click.testGetValue(), + "Pocket click was not recorded" + ); + Assert.ok( + !Glean.pocket.save.testGetValue(), + "Pocket save was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_click_with_no_value() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent correctly handles " + + "CLICK data with no value" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + }); + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + let topicClicks = Glean.pocket.topicClick.testGetValue(); + Assert.equal(topicClicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(topicClicks[0].extra, { + newtab_visit_id: SESSION_ID, + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_non_popular_click_with_no_value() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent correctly handles " + + "non-POPULAR_TOPICS CLICK data with no value" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + }); + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + Assert.ok( + !Glean.pocket.topicClick.testGetValue(), + "Pocket topicClick was not recorded" + ); + Assert.ok( + !Glean.pocket.click.testGetValue(), + "Pocket click was not recorded" + ); + Assert.ok( + !Glean.pocket.save.testGetValue(), + "Pocket save was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_non_popular_click() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent correctly handles " + + "CLICK data with non-POPULAR_TOPICS source" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const TOPIC = "atopic"; + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + value: { + card_type: "topics_widget", + topic: TOPIC, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + let topicClicks = Glean.pocket.topicClick.testGetValue(); + Assert.equal(topicClicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(topicClicks[0].extra, { + newtab_visit_id: SESSION_ID, + topic: TOPIC, + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_without_card_type() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent doesn't instrument " + + "a CLICK without a card_type" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + value: { + card_type: "not spoc, organic, or topics_widget", + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + + Assert.ok( + !Glean.pocket.topicClick.testGetValue(), + "Pocket topicClick was not recorded" + ); + Assert.ok( + !Glean.pocket.click.testGetValue(), + "Pocket click was not recorded" + ); + Assert.ok( + !Glean.pocket.save.testGetValue(), + "Pocket save was not recorded" + ); + + sandbox.restore(); + } +); + +add_task(async function test_handleDiscoveryStreamUserEvent_popular_click() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments a popular " + + "topic click" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const TOPIC = "entertainment"; + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + value: { + card_type: "topics_widget", + topic: TOPIC, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + let topicClicks = Glean.pocket.topicClick.testGetValue(); + Assert.equal(topicClicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(topicClicks[0].extra, { + newtab_visit_id: SESSION_ID, + topic: TOPIC, + }); + + sandbox.restore(); +}); + +add_task( + async function test_handleDiscoveryStreamUserEvent_organic_top_stories_click() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments an organic " + + "top stories click" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const ACTION_POSITION = 42; + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + action_position: ACTION_POSITION, + value: { + card_type: "organic", + recommendation_id: "decaf-c0ff33", + tile_id: 314623757745896, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + + let clicks = Glean.pocket.click.testGetValue(); + Assert.equal(clicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(clicks[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: ACTION_POSITION, + recommendation_id: "decaf-c0ff33", + tile_id: String(314623757745896), + }); + + Assert.ok( + !Glean.pocket.shim.testGetValue(), + "Pocket shim was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_sponsored_top_stories_click() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments a sponsored " + + "top stories click" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const ACTION_POSITION = 42; + const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + let action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + action_position: ACTION_POSITION, + value: { + card_type: "spoc", + recommendation_id: undefined, + tile_id: 448685088, + shim: SHIM, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + let pingSubmitted = new Promise(resolve => { + GleanPings.spoc.testBeforeNextSubmit(reason => { + Assert.equal(reason, "click"); + resolve(); + }); + }); + + instance.handleDiscoveryStreamUserEvent(action); + + let clicks = Glean.pocket.click.testGetValue(); + Assert.equal(clicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(clicks[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(true), + position: ACTION_POSITION, + tile_id: String(448685088), + }); + + await pingSubmitted; + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_organic_top_stories_save() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments a save of an " + + "organic top story" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const ACTION_POSITION = 42; + let action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position: ACTION_POSITION, + value: { + card_type: "organic", + recommendation_id: "decaf-c0ff33", + tile_id: 314623757745896, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + + let saves = Glean.pocket.save.testGetValue(); + Assert.equal(saves.length, 1, "Recorded 1 save"); + Assert.deepEqual(saves[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: ACTION_POSITION, + recommendation_id: "decaf-c0ff33", + tile_id: String(314623757745896), + }); + Assert.ok( + !Glean.pocket.shim.testGetValue(), + "Pocket shim was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_sponsored_top_stories_save() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments a save of a " + + "sponsored top story" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const ACTION_POSITION = 42; + const SHIM = "Y29uc2lkZXIgeW91ciBjdXJpb3NpdHkgcmV3YXJkZWQ="; + let action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position: ACTION_POSITION, + value: { + card_type: "spoc", + recommendation_id: undefined, + tile_id: 448685088, + shim: SHIM, + }, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + let pingSubmitted = new Promise(resolve => { + GleanPings.spoc.testBeforeNextSubmit(reason => { + Assert.equal(reason, "save"); + Assert.equal( + Glean.pocket.shim.testGetValue(), + SHIM, + "Pocket shim was recorded" + ); + + resolve(); + }); + }); + + instance.handleDiscoveryStreamUserEvent(action); + + let saves = Glean.pocket.save.testGetValue(); + Assert.equal(saves.length, 1, "Recorded 1 save"); + Assert.deepEqual(saves[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(true), + position: ACTION_POSITION, + tile_id: String(448685088), + }); + + await pingSubmitted; + + sandbox.restore(); + } +); + +add_task( + async function test_handleDiscoveryStreamUserEvent_sponsored_top_stories_save_no_value() { + info( + "TelemetryFeed.handleDiscoveryStreamUserEvent instruments a save of a " + + "sponsored top story, without `value`" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + const ACTION_POSITION = 42; + let action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position: ACTION_POSITION, + }); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleDiscoveryStreamUserEvent(action); + + let saves = Glean.pocket.save.testGetValue(); + Assert.equal(saves.length, 1, "Recorded 1 save"); + Assert.deepEqual(saves[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: ACTION_POSITION, + }); + Assert.ok( + !Glean.pocket.shim.testGetValue(), + "Pocket shim was not recorded" + ); + + sandbox.restore(); + } +); + +add_task( + async function test_handleAboutSponsoredTopSites_record_showPrivacyClick() { + info( + "TelemetryFeed.handleAboutSponsoredTopSites should record a Glean " + + "topsites.showPrivacyClick event on action" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + position: 42, + advertiser_name: "mozilla", + tile_id: 4567, + }; + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + instance.handleAboutSponsoredTopSites({ data }); + + let clicks = Glean.topsites.showPrivacyClick.testGetValue(); + Assert.equal(clicks.length, 1, "Recorded 1 click"); + Assert.deepEqual(clicks[0].extra, { + advertiser_name: data.advertiser_name, + tile_id: String(data.tile_id), + newtab_visit_id: SESSION_ID, + position: String(data.position), + }); + + sandbox.restore(); + } +); + +add_task( + async function test_handleAboutSponsoredTopSites_no_record_showPrivacyClick() { + info( + "TelemetryFeed.handleAboutSponsoredTopSites should not record a Glean " + + "topsites.showPrivacyClick event if there's no session" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + let data = { + position: 42, + advertiser_name: "mozilla", + tile_id: 4567, + }; + + sandbox.stub(instance.sessions, "get").returns(null); + + instance.handleAboutSponsoredTopSites({ data }); + + let clicks = Glean.topsites.showPrivacyClick.testGetValue(); + Assert.ok(!clicks, "Did not record any clicks"); + + sandbox.restore(); + } +); + +add_task(async function test_handleBlockUrl_no_record_dismisses() { + info( + "TelemetryFeed.handleBlockUrl shouldn't record events for pocket " + + "cards' dismisses" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + let data = [ + { + // Shouldn't record anything for this one + is_pocket_card: true, + position: 43, + tile_id: undefined, + }, + ]; + + await instance.handleBlockUrl({ data }); + + Assert.ok( + !Glean.topsites.dismiss.testGetValue(), + "Should not record a dismiss for Pocket cards" + ); + + sandbox.restore(); +}); + +add_task(async function test_handleBlockUrl_record_dismiss_on_action() { + info( + "TelemetryFeed.handleBlockUrl should record a topsites.dismiss event " + + "on action" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + let data = [ + { + is_pocket_card: false, + position: 42, + advertiser_name: "mozilla", + tile_id: 4567, + isSponsoredTopSite: 1, // for some reason this is an int. + }, + ]; + + await instance.handleBlockUrl({ data }); + + let dismisses = Glean.topsites.dismiss.testGetValue(); + Assert.equal(dismisses.length, 1, "Should have recorded 1 dismiss"); + Assert.deepEqual(dismisses[0].extra, { + advertiser_name: data[0].advertiser_name, + tile_id: String(data[0].tile_id), + newtab_visit_id: SESSION_ID, + is_sponsored: String(!!data[0].isSponsoredTopSite), + position: String(data[0].position), + }); + + sandbox.restore(); +}); + +add_task( + async function test_handleBlockUrl_record_dismiss_on_nonsponsored_action() { + info( + "TelemetryFeed.handleBlockUrl should record a Glean topsites.dismiss " + + "event on action on non-sponsored topsite" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + const SESSION_ID = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id: SESSION_ID }); + + let data = [ + { + is_pocket_card: false, + position: 42, + tile_id: undefined, + }, + ]; + + await instance.handleBlockUrl({ data }); + + let dismisses = Glean.topsites.dismiss.testGetValue(); + Assert.equal(dismisses.length, 1, "Should have recorded 1 dismiss"); + Assert.deepEqual(dismisses[0].extra, { + newtab_visit_id: SESSION_ID, + is_sponsored: String(false), + position: String(data[0].position), + }); + + sandbox.restore(); + } +); + +add_task(async function test_handleBlockUrl_no_record_dismiss_on_no_session() { + info( + "TelemetryFeed.handleBlockUrl should not record a Glean " + + "topsites.dismiss event if there's no session" + ); + + let sandbox = sinon.createSandbox(); + let instance = new TelemetryFeed(); + Services.fog.testResetFOG(); + + sandbox.stub(instance.sessions, "get").returns(null); + + let data = {}; + + await instance.handleBlockUrl({ data }); + + Assert.ok( + !Glean.topsites.dismiss.testGetValue(), + "Should not have recorded a dismiss" + ); + + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js new file mode 100644 index 0000000000..860e8758a5 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_TopSitesFeed.js @@ -0,0 +1,3397 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TopSitesFeed, DEFAULT_TOP_SITES } = ChromeUtils.importESModule( + "resource://activity-stream/lib/TopSitesFeed.sys.mjs" +); + +const { actionCreators: ac, actionTypes: at } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.sys.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", + 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 getTopSitesFeedForTest(sandbox) { + let 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) }, + }; + + return feed; +} + +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() { + let feed = new TopSitesFeed(); + Assert.ok(feed, "Could construct a TopSitesFeed"); + Assert.ok(feed._currentSearchHostname, "_currentSearchHostname defined"); +}); + +add_task(async function test_refreshDefaults() { + let sandbox = sinon.createSandbox(); + let feed = new TopSitesFeed(); + Assert.ok( + !DEFAULT_TOP_SITES.length, + "Should have 0 DEFAULT_TOP_SITES initially." + ); + + info("refreshDefaults should add defaults on PREFS_INITIAL_VALUES"); + feed.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"); + feed.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(feed, "refresh"); + feed.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } }); + Assert.ok(feed.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"); + feed.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"); + feed.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"); + feed.refreshDefaults(""); + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + info("refreshDefaults should be able to clear defaults"); + feed.refreshDefaults("https://foo.com"); + feed.refreshDefaults(""); + + Assert.equal( + DEFAULT_TOP_SITES.length, + 0, + "Should have 0 DEFAULT_TOP_SITES now." + ); + + sandbox.restore(); +}); + +add_task(async function test_filterForThumbnailExpiration() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + info( + "filterForThumbnailExpiration 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 = sandbox.stub(); + feed.filterForThumbnailExpiration(stub); + Assert.ok(stub.calledOnce); + Assert.ok(stub.calledWithExactly(["foo.com", "bar.com", "custom"])); + + sandbox.restore(); +}); + +add_task( + async function test_getLinksWithDefaults_on_SearchService_init_failure() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed.refreshDefaults("https://foo.com"); + + gSearchServiceInitStub.rejects(new Error("Simulating search init failure")); + + const result = await feed.getLinksWithDefaults(); + Assert.ok(result); + + gSearchServiceInitStub.resolves(); + + sandbox.restore(); + } +); + +add_task(async function test_getLinksWithDefaults() { + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed.refreshDefaults("https://foo.com"); + + info("getLinksWithDefaults should get the links from NewTabUtils"); + let result = await feed.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(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + const result = await feed.getLinksWithDefaults(); + Assert.ok(FilterAdult.filter.calledOnce); + Assert.equal(result.length, 1); + Assert.equal(result[0].url, TEST_URL); + + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + 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.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(); +}); + +add_task(async function test_getLinksWithDefaults_dedupe() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should call dedupe.group on the links"); + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + let stub = sandbox.stub(feed.dedupe, "group").callsFake((...id) => id); + await feed.getLinksWithDefaults(); + + Assert.ok(stub.calledOnce, "dedupe.group was called once"); + sandbox.restore(); +}); + +add_task(async function test__dedupe_key() { + let sandbox = sinon.createSandbox(); + + info("_dedupeKey should dedupe on hostname instead of url"); + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + let site = { url: "foo", hostname: "bar" }; + let result = feed._dedupeKey(site); + + Assert.equal(result, site.hostname, "deduped on hostname"); + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + let result = await feed.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(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + let result = await feed.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(); + } +); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + feed.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults did not throw"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + const TEST_ROWS = 3; + feed.store.state.Prefs.values.topSitesRows = TEST_ROWS; + + let result = await feed.getLinksWithDefaults(); + Assert.equal(result.length, TEST_ROWS * TOP_SITES_MAX_SITES_PER_ROW); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +add_task(async function test_getLinksWithDefaults_reuse_cache() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should reuse the cache on subsequent calls"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await feed.getLinksWithDefaults(); + await feed.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + await feed.getLinksWithDefaults(); + feed.store.state.Prefs.values.topSitesRows *= 3; + await feed.getLinksWithDefaults(); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledTwice, + "getTopSites called twice" + ); + + sandbox.restore(); + } +); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + await feed.getLinksWithDefaults(); + + let originalCallCount = Screenshots.getScreenshotForURL.callCount; + feed.frecentCache.expire(); + + let result = await feed.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(); + } +); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + gGetTopSitesStub.resetHistory(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + await feed.getLinksWithDefaults(); + + let originalCallCount = + NewTabUtils.activityStreamProvider._addFavicons.callCount; + feed.pinnedCache.expire(); + + let result = await feed.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(); + } +); + +add_task(async function test_getLinksWithDefaults_no_internal_properties() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not expose internal link properties"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + let result = await feed.getLinksWithDefaults(); + + let internal = Object.keys(result[0]).filter(key => key.startsWith("__")); + Assert.equal(internal.join(""), ""); + + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, TEST_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, undefined); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, CUSTOM_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +add_task( + async function test_getLinksWithDefaults_no_overwrite_pinned_screenshot() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not overwrite pinned site screenshot"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults(); + + Assert.equal(result[0].screenshot, EXISTING_SCREENSHOT); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); + } +); + +add_task( + async function test_getLinksWithDefaults_no_searchTopSite_from_frecent() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not set searchTopSite from frecent site"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.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(); + } +); + +add_task(async function test_getLinksWithDefaults_concurrency_getTopSites() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + + await Promise.all([feed.getLinksWithDefaults(), feed.getLinksWithDefaults()]); + + Assert.ok( + NewTabUtils.activityStreamLinks.getTopSites.calledOnce, + "getTopSites only called once" + ); + + sandbox.restore(); +}); + +add_task( + async function test_getLinksWithDefaults_concurrency_getScreenshotForURL() { + let sandbox = sinon.createSandbox(); + info( + "getLinksWithDefaults concurrent calls should call the backing data once" + ); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults("https://foo.com"); + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + NewTabUtils.activityStreamLinks.getTopSites.resetHistory(); + Screenshots.getScreenshotForURL.resetHistory(); + + await Promise.all([ + feed.getLinksWithDefaults(), + feed.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" + ); + + feed = getTopSitesFeedForTest(sandbox); + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + + feed.refreshDefaults("https://foo.com"); + + sandbox.stub(feed, "_requestRichIcon"); + await Promise.all([ + feed.getLinksWithDefaults(), + feed.getLinksWithDefaults(), + ]); + + Assert.equal( + feed.store.dispatch.callCount, + FAKE_LINKS.length, + "getLinksWithDefaults concurrent calls should dispatch once per link screenshot fetched" + ); + + sandbox.restore(); + } +); + +add_task(async function test_getLinksWithDefaults_deduping_no_dedupe_pinned() { + let sandbox = sinon.createSandbox(); + info("getLinksWithDefaults should not dedupe pinned sites"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.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(); +}); + +add_task(async function test_getLinksWithDefaults_prefer_pinned_sites() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should prefer pinned sites over links"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.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 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.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(); +}); + +add_task(async function test_getLinksWithDefaults_title_and_null() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults(); + + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://github.com/mozilla/activity-stream" }]); + + let sites = await feed.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 feed.getLinksWithDefaults(); + Assert.ok(true, "getLinksWithDefaults didn't throw"); + + sandbox.restore(); +}); + +add_task(async function test_getLinksWithDefaults_calls__fetchIcon() { + let sandbox = sinon.createSandbox(); + + info("getLinksWithDefaults should return sites that have a title"); + + let feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults(); + + sandbox.spy(feed, "_fetchIcon"); + let results = await feed.getLinksWithDefaults(); + Assert.ok(results.length, "Got back some results"); + Assert.equal(feed._fetchIcon.callCount, results.length); + for (let result of results) { + Assert.ok(feed._fetchIcon.calledWith(result)); + } + + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults(); + + sandbox.stub(feed, "_fetchScreenshot"); + await feed.getLinksWithDefaults(); + + Assert.ok(feed._fetchScreenshot.calledWith(sinon.match.object, "custom")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +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 feed = getTopSitesFeedForTest(sandbox); + feed.refreshDefaults(); + + 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"); + } + + sandbox.restore(); +}); + +add_task(async function test_init() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let feed = getTopSitesFeedForTest(sandbox); + + sandbox.stub(feed, "refresh"); + await feed.init(); + + info("TopSitesFeed.init should call refresh (broadcast: true)"); + Assert.ok(feed.refresh.calledOnce, "refresh called once"); + Assert.ok( + feed.refresh.calledWithExactly({ + broadcast: true, + isStartup: true, + }) + ); + + info("TopSitesFeed.init should initialise the storage"); + Assert.ok( + feed.store.dbStorage.getDbTable.calledOnce, + "getDbTable called once" + ); + Assert.ok(feed.store.dbStorage.getDbTable.calledWithExactly("sectionPrefs")); + + info( + "TopSitesFeed.init should call onUpdate to set up Nimbus update listener" + ); + + Assert.ok( + NimbusFeatures.newtab.onUpdate.calledOnce, + "NimbusFeatures.newtab.onUpdate called once" + ); + sandbox.restore(); +}); + +add_task(async function test_refresh() { + let sandbox = sinon.createSandbox(); + + sandbox.stub(NimbusFeatures.newtab, "onUpdate"); + + let feed = getTopSitesFeedForTest(sandbox); + + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + + info("TopSitesFeed.refresh should wait for tippytop to initialize"); + feed._tippyTopProvider.initialized = false; + sandbox.stub(feed._tippyTopProvider, "init").resolves(); + + await feed.refresh(); + + Assert.ok( + feed._tippyTopProvider.init.calledOnce, + "feed._tippyTopProvider.init called once" + ); + + info( + "TopSitesFeed.refresh should not init the tippyTopProvider if already initialized" + ); + feed._tippyTopProvider.initialized = true; + feed._tippyTopProvider.init.resetHistory(); + + await feed.refresh(); + + Assert.ok( + feed._tippyTopProvider.init.notCalled, + "tippyTopProvider not initted again" + ); + + info("TopSitesFeed.refresh should broadcast TOP_SITES_UPDATED"); + feed.store.dispatch.resetHistory(); + sandbox.stub(feed, "getLinksWithDefaults").resolves([]); + + await feed.refresh({ broadcast: true }); + + Assert.ok(feed.store.dispatch.calledOnce, "dispatch called once"); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_refresh_dispatch() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.refresh should dispatch an action with the links returned" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + + await feed.refresh({ broadcast: true }); + let reference = FAKE_LINKS.map(site => + Object.assign({}, site, { + hostname: shortURL(site), + typedBonus: true, + }) + ); + + Assert.ok(feed.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.TOP_SITES_UPDATED + ); + Assert.deepEqual(feed.store.dispatch.firstCall.args[0].data.links, reference); + + sandbox.restore(); +}); + +add_task(async function test_refresh_empty_slots() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.refresh should handle empty slots in the resulting top sites array" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "_fetchIcon"); + feed._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 feed.refresh({ broadcast: true }); + + Assert.ok(feed.store.dispatch.calledOnce, "Store.dispatch called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +add_task(async function test_refresh_to_preloaded() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + + gGetTopSitesStub.resolves([]); + await feed.refresh({ broadcast: false }); + + Assert.ok(feed.store.dispatch.calledOnce, "Store.dispatch called once"); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.AlsoToPreloaded({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ) + ); + gGetTopSitesStub.resolves(FAKE_LINKS); + sandbox.restore(); +}); + +add_task(async function test_refresh_init_storage() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.refresh should not init storage of it's already initialized" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + + feed._storage.initialized = true; + + await feed.refresh({ broadcast: false }); + + Assert.ok(feed._storage.init.notCalled, "feed._storage.init was not called."); + sandbox.restore(); +}); + +add_task(async function test_refresh_handles_indexedDB_errors() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.refresh should dispatch AlsoToPreloaded when broadcast is false" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + + feed._storage.get.throws(new Error()); + + try { + await feed.refresh({ broadcast: false }); + Assert.ok(true, "refresh should have succeeded"); + } catch (e) { + Assert.ok(false, "Should not have thrown"); + } + + sandbox.restore(); +}); + +add_task(async function test_updateSectionPrefs_on_UPDATE_SECTION_PREFS() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.onAction should call updateSectionPrefs on UPDATE_SECTION_PREFS" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "updateSectionPrefs"); + feed.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topsites" }, + }); + + Assert.ok( + feed.updateSectionPrefs.calledOnce, + "feed.updateSectionPrefs called once" + ); + + sandbox.restore(); +}); + +add_task( + async function test_updateSectionPrefs_dispatch_TOP_SITES_PREFS_UPDATED() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.updateSectionPrefs should dispatch TOP_SITES_PREFS_UPDATED" + ); + + let feed = getTopSitesFeedForTest(sandbox); + await feed.updateSectionPrefs({ collapsed: true }); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: { collapsed: true } }, + }) + ) + ); + + sandbox.restore(); + } +); + +add_task(async function test_allocatePositions() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.allocationPositions should allocate positions and dispatch" + ); + + let feed = getTopSitesFeedForTest(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(feed._contile, "sov").get(() => sov); + + sandbox.stub(Sampling, "ratioSample"); + Sampling.ratioSample.onCall(0).resolves(0); + Sampling.ratioSample.onCall(1).resolves(1); + + await feed.allocatePositions(); + + Assert.ok(feed.store.dispatch.calledOnce, "feed.store.dispatch called once"); + Assert.ok( + feed.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 feed.allocatePositions(); + + Assert.ok( + feed.store.dispatch.calledTwice, + "feed.store.dispatch called twice" + ); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { position: 1, assignedPartner: "amp" }, + { position: 2, assignedPartner: "amp" }, + ], + }, + }) + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.getScreenshotPreview should dispatch preview if request is succesful" + ); + + let feed = getTopSitesFeedForTest(sandbox); + await feed.getScreenshotPreview("custom", 1234); + + Assert.ok(feed.store.dispatch.calledOnce); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: FAKE_SCREENSHOT, url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + sandbox.restore(); +}); + +add_task(async function test_getScreenshotPreview() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.getScreenshotPreview should return empty string if request fails" + ); + + let feed = getTopSitesFeedForTest(sandbox); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await feed.getScreenshotPreview("custom", 1234); + + Assert.ok(feed.store.dispatch.calledOnce); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + sandbox.restore(); +}); + +add_task(async function test_onAction_part_1() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.onAction should call getScreenshotPreview on PREVIEW_REQUEST" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "getScreenshotPreview"); + + feed.onAction({ + type: at.PREVIEW_REQUEST, + data: { url: "foo" }, + meta: { fromTarget: 1234 }, + }); + + Assert.ok( + feed.getScreenshotPreview.calledOnce, + "feed.getScreenshotPreview called once" + ); + Assert.ok(feed.getScreenshotPreview.calledWithExactly("foo", 1234)); + + info("TopSitesFeed.onAction should refresh on SYSTEM_TICK"); + sandbox.stub(feed, "refresh"); + feed.onAction({ type: at.SYSTEM_TICK }); + + Assert.ok(feed.refresh.calledOnce, "feed.refresh called once"); + Assert.ok(feed.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSitesFeed.onAction should call with correct parameters on TOP_SITES_PIN" + ); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.spy(feed, "pin"); + + let pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: 7 }, + }; + feed.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( + feed.pin.calledOnce, + "TopSitesFeed.onAction should call pin on TOP_SITES_PIN" + ); + + info( + "TopSitesFeed.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 }, + }; + feed.onAction(pinAction); + Assert.ok( + NewTabUtils.blockedLinks.unblock.calledWith({ + url: pinAction.data.site.url, + }) + ); + + info("TopSitesFeed.onAction should call insert on TOP_SITES_INSERT"); + sandbox.stub(feed, "insert"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + feed.onAction(addAction); + Assert.ok(feed.insert.calledOnce, "TopSitesFeed.insert called once"); + + info( + "TopSitesFeed.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" } }, + }; + feed.onAction(unpinAction); + Assert.ok( + NewTabUtils.pinnedLinks.unpin.calledOnce, + "NewTabUtils.pinnedLinks.unpin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.unpin.calledWith(unpinAction.data.site)); + + sandbox.restore(); +}); + +add_task(async function test_onAction_part_2() { + let sandbox = sinon.createSandbox(); + + info( + "TopSitesFeed.onAction should call refresh without a target if we clear " + + "history with PLACES_HISTORY_CLEARED" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "refresh"); + feed.onAction({ type: at.PLACES_HISTORY_CLEARED }); + + Assert.ok(feed.refresh.calledOnce, "TopSitesFeed.refresh called once"); + Assert.ok(feed.refresh.calledWithExactly({ broadcast: true })); + + feed.refresh.resetHistory(); + + info( + "TopSitesFeed.onAction should call refresh without a target " + + "if we remove a Topsite from history" + ); + feed.onAction({ type: at.PLACES_LINKS_DELETED }); + + Assert.ok(feed.refresh.calledOnce, "TopSitesFeed.refresh called once"); + Assert.ok(feed.refresh.calledWithExactly({ broadcast: true })); + + info("TopSitesFeed.onAction should call init on INIT action"); + feed.onAction({ type: at.PLACES_LINKS_DELETED }); + sandbox.stub(feed, "init"); + feed.onAction({ type: at.INIT }); + Assert.ok(feed.init.calledOnce, "TopSitesFeed.init called once"); + + info( + "TopSitesFeed.onAction should call refresh on PLACES_LINK_BLOCKED action" + ); + feed.refresh.resetHistory(); + await feed.onAction({ type: at.PLACES_LINK_BLOCKED }); + Assert.ok(feed.refresh.calledOnce, "TopSitesFeed.refresh called once"); + Assert.ok(feed.refresh.calledWithExactly({ broadcast: true })); + + info( + "TopSitesFeed.onAction should call refresh on PLACES_LINKS_CHANGED action" + ); + feed.refresh.resetHistory(); + await feed.onAction({ type: at.PLACES_LINKS_CHANGED }); + Assert.ok(feed.refresh.calledOnce, "TopSitesFeed.refresh called once"); + Assert.ok(feed.refresh.calledWithExactly({ broadcast: false })); + + info( + "TopSitesFeed.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" } }, + }; + feed.onAction(addAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(addAction.data.site, 0)); + + info( + "TopSitesFeed.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 }, + }; + feed.onAction(dropAction); + Assert.ok( + NewTabUtils.pinnedLinks.pin.calledOnce, + "NewTabUtils.pinnedLinks.pin called once" + ); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(dropAction.data.site, 3)); + + // feed.init needs to actually run in order to register the observers that'll + // be removed in the following UNINIT test, otherwise uninit will throw. + feed.init.restore(); + feed.init(); + + info("TopSitesFeed.onAction should remove the expiration filter on UNINIT"); + sandbox.stub(PageThumbs, "removeExpirationFilter"); + feed.onAction({ type: "UNINIT" }); + Assert.ok( + PageThumbs.removeExpirationFilter.calledOnce, + "PageThumbs.removeExpirationFilter called once" + ); + + sandbox.restore(); +}); + +add_task(async function test_onAction_part_3() { + let sandbox = sinon.createSandbox(); + + let feed = getTopSitesFeedForTest(sandbox); + + info( + "TopSitesFeed.onAction should call updatePinnedSearchShortcuts " + + "on UPDATE_PINNED_SEARCH_SHORTCUTS action" + ); + sandbox.stub(feed, "updatePinnedSearchShortcuts"); + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + await feed.onAction({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { addedShortcuts }, + }); + Assert.ok( + feed.updatePinnedSearchShortcuts.calledOnce, + "TopSitesFeed.updatePinnedSearchShortcuts called once" + ); + + info( + "TopSitesFeed.onAction should refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is enabled" + ); + sandbox.spy(feed._contile, "refresh"); + let prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF }, + }; + sandbox.stub(NimbusFeatures.newtab, "getVariable").returns(true); + feed.onAction(prefChangeAction); + + Assert.ok( + feed._contile.refresh.calledOnce, + "TopSitesFeed._contile.refresh called once" + ); + + info( + "TopSitesFeed.onAction should not refresh from Contile on " + + "SHOW_SPONSORED_PREF if Contile is disabled" + ); + NimbusFeatures.newtab.getVariable.returns(false); + feed._contile.refresh.resetHistory(); + feed.onAction(prefChangeAction); + + Assert.ok( + !feed._contile.refresh.calledOnce, + "TopSitesFeed._contile.refresh never called" + ); + + info( + "TopSitesFeed.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); + feed._contile.refresh.resetHistory(); + + feed.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(); +}); + +add_task(async function test_insert_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info( + "TopSitesFeed.insert should pin site in first slot of empty pinned list" + ); + + let feed = getTopSitesFeedForTest(sandbox); + Screenshots.getScreenshotForURL.resolves(Promise.resolve(null)); + await feed.getScreenshotPreview("custom", 1234); + + Assert.ok(feed.store.dispatch.calledOnce); + Assert.ok( + feed.store.dispatch.calledWithExactly( + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ) + ); + + Screenshots.getScreenshotForURL.resolves(FAKE_SCREENSHOT); + } + + { + info( + "TopSitesFeed.insert should pin site in first slot of pinned list with " + + "empty first slot" + ); + + let feed = getTopSitesFeedForTest(sandbox); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [null, { url: "example.com" }]); + let site = { url: "foo.bar", label: "foo" }; + await feed.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( + "TopSitesFeed.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 feed = getTopSitesFeedForTest(sandbox); + let site = { url: "foo.bar", label: "foo" }; + + await feed.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( + "TopSitesFeed.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" }; + let feed = getTopSitesFeedForTest(sandbox); + await feed.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(); +}); + +add_task(async function test_insert_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info( + "TopSitesFeed.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]); + let feed = getTopSitesFeedForTest(sandbox); + feed.store.state.Prefs.values.topSitesRows = 1; + let site = { url: "foo.bar", label: "foo" }; + await feed.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("TopSitesFeed.insert should trigger refresh on TOP_SITES_INSERT"); + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "refresh"); + let addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + await feed.insert(addAction); + + Assert.ok(feed.refresh.calledOnce, "feed.refresh called once"); + } + + { + info("TopSitesFeed.insert should correctly handle different index values"); + let index = -1; + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index, site } }; + let feed = getTopSitesFeedForTest(sandbox); + + await feed.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + index = undefined; + await feed.insert(action); + Assert.ok(NewTabUtils.pinnedLinks.pin.calledWith(site, 0)); + + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); +}); + +add_task(async function test_insert_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info("TopSitesFeed.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" }; + let feed = getTopSitesFeedForTest(sandbox); + + await feed.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( + "TopSitesFeed.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" }; + let feed = getTopSitesFeedForTest(sandbox); + + await feed.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( + "TopSitesFeed.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]); + + let feed = getTopSitesFeedForTest(sandbox); + + await feed.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 feed.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("TopSitesFeed.insert should not insert past the visible top sites"); + + let feed = getTopSitesFeedForTest(sandbox); + let site1 = { url: "foo.bar", label: "foo" }; + await feed.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(); +}); + +add_task(async function test_pin_part_1() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info( + "TopSitesFeed.pin should pin site in specified slot empty pinned " + + "list" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + let feed = getTopSitesFeedForTest(sandbox); + await feed.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(); + } + + { + info( + "TopSitesFeed.pin should lookup the link object to update the custom " + + "screenshot" + ); + let site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + let feed = getTopSitesFeedForTest(sandbox); + sandbox.spy(feed.pinnedCache, "request"); + await feed.pin({ data: { index: 2, site } }); + + Assert.ok( + feed.pinnedCache.request.calledOnce, + "feed.pinnedCache.request called once" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSitesFeed.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, + }; + let feed = getTopSitesFeedForTest(sandbox); + sandbox.spy(feed.pinnedCache, "request"); + await feed.pin({ data: { index: 2, site } }); + + Assert.ok( + feed.pinnedCache.request.calledOnce, + "feed.pinnedCache.request called once" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSitesFeed.pin should not do a link object lookup if custom " + + "screenshot field is not set" + ); + let site = { url: "foo.bar", label: "foo" }; + let feed = getTopSitesFeedForTest(sandbox); + sandbox.spy(feed.pinnedCache, "request"); + await feed.pin({ data: { index: 2, site } }); + + Assert.ok( + !feed.pinnedCache.request.called, + "feed.pinnedCache.request never called" + ); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info( + "TopSitesFeed.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" }; + let feed = getTopSitesFeedForTest(sandbox); + await feed.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(); +}); + +add_task(async function test_pin_part_2() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info("TopSitesFeed.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 feed = getTopSitesFeedForTest(sandbox); + await feed.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(); + } + + { + info( + "TopSitesFeed.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 feed = getTopSitesFeedForTest(sandbox); + await feed.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(); + } + + { + info( + "TopSitesFeed.pin should properly update LinksCache object " + + "properties between migrations" + ); + sandbox + .stub(NewTabUtils.pinnedLinks, "links") + .get(() => [{ url: "https://foo.com/" }]); + + let feed = getTopSitesFeedForTest(sandbox); + 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.equal(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.equal(pinnedLinks[0].screenshot, "bar"); + } + + sandbox.restore(); +}); + +add_task(async function test_pin_part_3() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + + { + info("TopSitesFeed.pin should call insert if index < 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: -1, site } }; + let feed = getTopSitesFeedForTest(sandbox); + sandbox.spy(feed, "insert"); + await feed.pin(action); + + Assert.ok(feed.insert.calledOnce, "feed.insert called once"); + Assert.ok(feed.insert.calledWithExactly(action)); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSitesFeed.pin should not call insert if index == 0"); + let site = { url: "foo.bar", label: "foo" }; + let action = { data: { index: 0, site } }; + let feed = getTopSitesFeedForTest(sandbox); + sandbox.spy(feed, "insert"); + await feed.pin(action); + + Assert.ok(!feed.insert.called, "feed.insert not called"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + { + info("TopSitesFeed.pin should trigger refresh on TOP_SITES_PIN"); + let feed = getTopSitesFeedForTest(sandbox); + sandbox.stub(feed, "refresh"); + let pinExistingAction = { + type: at.TOP_SITES_PIN, + data: { site: FAKE_LINKS[4], index: 4 }, + }; + + await feed.pin(pinExistingAction); + + Assert.ok(feed.refresh.calledOnce, "feed.refresh called once"); + NewTabUtils.pinnedLinks.pin.resetHistory(); + } + + sandbox.restore(); +}); + +add_task(async function test_integration() { + let sandbox = sinon.createSandbox(); + + info("Test adding a pinned site and removing it with actions"); + let feed = getTopSitesFeedForTest(sandbox); + + let resolvers = []; + feed.store.dispatch = sandbox.stub().callsFake(() => { + resolvers.shift()(); + }); + feed._startedUp = true; + sandbox.stub(feed, "_fetchScreenshot"); + + let forDispatch = action => + new Promise(resolve => { + resolvers.push(resolve); + feed.onAction(action); + }); + + feed._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( + feed.store.dispatch.calledTwice, + "feed.store.dispatch called twice" + ); + 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 + ); + + sandbox.restore(); +}); + +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( + "TopSitesFeed.getLinksWithDefaults should filter out alexa top 5 " + + "search from the default sites" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.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); + } + + { + info( + "TopSitesFeed.getLinksWithDefaults should not filter out alexa, default " + + "search from the query results if the experiment pref is off" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults()).map( + link => link.url + ); + + Assert.ok(urlsReturned.includes("https://google.com")); + gGetTopSitesStub.resolves(FAKE_LINKS); + } + + { + info( + "TopSitesFeed.getLinksWithDefaults should filter out the current " + + "default search from the default sites" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + + sandbox.stub(feed, "_currentSearchHostname").get(() => "amazon"); + feed.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + + let urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + } + + { + info( + "TopSitesFeed.getLinksWithDefaults should not filter out current " + + "default search from pinned sites even if it matches the current " + + "default search" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.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 feed.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(urlsReturned.includes("google.com")); + + gGetTopSitesStub.resolves(FAKE_LINKS); + } + + 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", + }); + + { + info( + "TopSitesFeed.getLinksWithDefaults should call refresh and set " + + "._currentSearchHostname to the new engine hostname when the " + + "default search engine has been set" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + sandbox.stub(feed, "refresh"); + + feed.observe(null, "browser-search-engine-modified", "engine-default"); + Assert.equal(feed._currentSearchHostname, "duckduckgo"); + Assert.ok(feed.refresh.calledOnce, "feed.refresh called once"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + } + + { + info( + "TopSitesFeed.getLinksWithDefaults should call refresh when the " + + "experiment pref has changed" + ); + let feed = getTopSitesFeedForTest(sandbox); + feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + sandbox.stub(feed, "refresh"); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true }, + }); + Assert.ok(feed.refresh.calledOnce, "feed.refresh was called once"); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false }, + }); + Assert.ok(feed.refresh.calledTwice, "feed.refresh was called twice"); + + gGetTopSitesStub.resolves(FAKE_LINKS); + } + + 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 prepFeed = feed => { + 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] = ""; + return feed; + }; + + { + info( + "TopSitesFeed should updateCustomSearchShortcuts when experiment " + + "pref is turned on" + ); + let feed = prepFeed(getTopSitesFeedForTest(sandbox)); + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + feed.updateCustomSearchShortcuts = sandbox.spy(); + + // turn the experiment on + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true }, + }); + + Assert.ok( + feed.updateCustomSearchShortcuts.calledOnce, + "feed.updateCustomSearchShortcuts called once" + ); + } + + { + info( + "TopSitesFeed should filter out default top sites that match a " + + "hostname of a search shortcut if previously blocked" + ); + let feed = prepFeed(getTopSitesFeedForTest(sandbox)); + feed.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 feed.getLinksWithDefaults()).map( + link => link.url + ); + Assert.ok(!urlsReturned.includes("https://amazon.ca")); + } + + { + info("TopSitesFeed should update frecent search topsite icon"); + let feed = prepFeed(getTopSitesFeedForTest(sandbox)); + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + gGetTopSitesStub.resolves([{ url: "https://google.com" }]); + + let urlsReturned = await feed.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); + } + + { + info("TopSitesFeed should update default search topsite icon"); + let feed = prepFeed(getTopSitesFeedForTest(sandbox)); + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + gGetTopSitesStub.resolves([{ url: "https://foo.com" }]); + feed.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + + let urlsReturned = await feed.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); + } + + { + info( + "TopSitesFeed should dispatch UPDATE_SEARCH_SHORTCUTS on " + + "updateCustomSearchShortcuts" + ); + let feed = prepFeed(getTopSitesFeedForTest(sandbox)); + feed.store.state.Prefs.values["improvesearch.noDefaultSearchTile"] = true; + await feed.updateCustomSearchShortcuts(); + Assert.ok( + feed.store.dispatch.calledOnce, + "feed.store.dispatch called once" + ); + Assert.ok( + feed.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", + }) + ); + } + + sandbox.restore(); +}); + +add_task(async function test_updatePinnedSearchShortcuts() { + let sandbox = sinon.createSandbox(); + sandbox.stub(NewTabUtils.pinnedLinks, "pin"); + sandbox.stub(NewTabUtils.pinnedLinks, "unpin"); + + { + info( + "TopSitesFeed.updatePinnedSearchShortcuts should unpin a " + + "shortcut in deletedShortcuts" + ); + let feed = getTopSitesFeedForTest(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, + }, + ]); + + feed.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(); + } + + { + info( + "TopSitesFeed.updatePinnedSearchShortcuts should pin a shortcut " + + "in addedShortcuts" + ); + let feed = getTopSitesFeedForTest(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, + }, + ]); + feed.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(); + } + + { + info( + "TopSitesFeed.updatePinnedSearchShortcuts should pin and unpin " + + "in the same action" + ); + let feed = getTopSitesFeedForTest(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, + }, + ]); + + feed.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(); + } + + { + info( + "TopSitesFeed.updatePinnedSearchShortcuts should pin a shortcut in " + + "addedShortcuts even if pinnedLinks is full" + ); + let feed = getTopSitesFeedForTest(sandbox); + + let addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + let deletedShortcuts = []; + sandbox.stub(NewTabUtils.pinnedLinks, "links").get(() => FAKE_LINKS); + feed.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(); + } + + 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 prepFeed = feed => { + feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + let fetchStub = sandbox.stub(feed, "fetch"); + return { feed, fetchStub }; + }; + + { + info("TopSitesFeed._fetchSites should fetch sites from Contile"); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + 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 feed._contile._fetchSites(); + + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + } + + { + info("TopSitesFeed._fetchSites should call allocatePositions"); + let { feed } = prepFeed(getTopSitesFeedForTest(sandbox)); + sandbox.stub(feed, "allocatePositions").resolves(); + await feed._contile.refresh(); + + Assert.ok( + feed.allocatePositions.calledOnce, + "feed.allocatePositions called once" + ); + } + + { + info( + "TopSitesFeed._fetchSites should fetch SOV (Share-of-Voice) " + + "settings from Contile" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 feed._contile._fetchSites(); + + Assert.ok(fetched); + Assert.deepEqual(feed._contile.sov, sov); + Assert.equal(feed._contile.sites.length, 2); + } + + { + info( + "TopSitesFeed._fetchSites should not fetch from Contile if " + + "it's not enabled" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + NimbusFeatures.newtab.getVariable.reset(); + NimbusFeatures.newtab.getVariable.returns(false); + let fetched = await feed._contile._fetchSites(); + + Assert.ok(fetchStub.notCalled, "TopSitesFeed.fetch was not called"); + Assert.ok(!fetched); + Assert.equal(feed._contile.sites.length, 0); + } + + { + info( + "TopSitesFeed._fetchSites should still return two tiles when Contile " + + "provides more than 2 tiles and filtering results in more than 2 tiles" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 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"); + } + + { + info( + "TopSitesFeed._fetchSites should still return two tiles with " + + "replacement if the Nimbus variable was unset" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 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"); + } + + { + info("TopSitesFeed._fetchSites should filter the blocked sponsors"); + NimbusFeatures.newtab.getVariable.returns(true); + + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 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"); + } + + { + info( + "TopSitesFeed._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 { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await feed._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!feed._contile.sites.length); + } + + { + info( + "TopSitesFeed._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 { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + fetchStub.resolves({ + ok: false, + status: 500, + }); + + let fetched = await feed._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!feed._contile.sites.length); + } + + { + info( + "TopSitesFeed._fetchSites should handle invalid payload " + + "properly from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + unknown: [], + }), + }); + + let fetched = await feed._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!feed._contile.sites.length); + } + + { + info( + "TopSitesFeed._fetchSites should handle empty payload properly " + + "from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + 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 feed._contile._fetchSites(); + + Assert.ok(fetched); + Assert.ok(!feed._contile.sites.length); + } + + { + info( + "TopSitesFeed._fetchSites should handle no content properly " + + "from Contile" + ); + NimbusFeatures.newtab.getVariable.returns(true); + + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + fetchStub.resolves({ ok: true, status: 204 }); + + let fetched = await feed._contile._fetchSites(); + + Assert.ok(!fetched); + Assert.ok(!feed._contile.sites.length); + } + + { + info( + "TopSitesFeed._fetchSites should set Caching Prefs after " + + "a successful request" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 feed._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 + ); + } + + { + info( + "TopSitesFeed._fetchSites should return cached valid tiles " + + "when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + 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 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"); + } + + { + info( + "TopSitesFeed._fetchSites should not be successful when contile " + + "returns an error and no valid tiles are cached" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + 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 feed._contile._fetchSites(); + Assert.ok(!fetched); + } + + { + info( + "TopSitesFeed._fetchSites should return cached valid tiles " + + "filtering blocked tiles when Contile returns error status" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 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"); + } + + { + info( + "TopSitesFeed._fetchSites should still return 3 tiles when nimbus " + + "variable overrides max num of sponsored contile tiles" + ); + NimbusFeatures.newtab.getVariable.returns(true); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox)); + + 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 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"); + } + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js b/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js new file mode 100644 index 0000000000..5d13df0eb0 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/test_TopSitesFeed_glean.js @@ -0,0 +1,2023 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + SearchService: "resource://gre/modules/SearchService.sys.mjs", + TopSitesFeed: "resource://activity-stream/lib/TopSitesFeed.sys.mjs", +}); + +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +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"; +const NIMBUS_VARIABLE_MAX_SPONSORED = "topSitesMaxSponsored"; +const NIMBUS_VARIABLE_CONTILE_POSITIONS = "contileTopsitesPositions"; +const NIMBUS_VARIABLE_CONTILE_ENABLED = "topSitesContileEnabled"; +const NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED = "topSitesContileMaxSponsored"; + +let contileTile1 = { + id: 74357, + name: "Brand1", + url: "https://www.brand1.com", + click_url: "https://clickurl.com", + image_url: "https://contile-images.jpg", + image_size: 200, + impression_url: "https://impression_url.com", +}; +let contileTile2 = { + id: 74925, + name: "Brand2", + url: "https://www.brand2.com", + click_url: "https://click_url.com", + image_url: "https://contile-images.jpg", + image_size: 200, + impression_url: "https://impression_url.com", +}; +let contileTile3 = { + id: 75001, + name: "Brand3", + url: "https://www.brand3.com", + click_url: "https://click_url.com", + image_url: "https://contile-images.jpg", + image_size: 200, + impression_url: "https://impression_url.com", +}; +let mozSalesTile = [ + { + label: "MozSales Title", + title: "MozSales Title", + url: "https://mozsale.net", + sponsored_position: 1, + partner: "moz-sales", + }, +]; + +function getTopSitesFeedForTest(sandbox) { + let feed = new TopSitesFeed(); + const storage = { + init: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + + 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) }, + }; + + return feed; +} + +function prepFeed(feed, sandbox) { + feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + let fetchStub = sandbox.stub(feed, "fetch"); + return { feed, fetchStub }; +} + +function setNimbusVariablesForNumTiles(nimbusPocketStub, numTiles) { + nimbusPocketStub.withArgs(NIMBUS_VARIABLE_MAX_SPONSORED).returns(numTiles); + nimbusPocketStub + .withArgs(NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED) + .returns(numTiles); + // when setting num tiles to > 2 need to set the positions or the > 2 has no effect. + // can be defaulted to undefined + let positionsArray = Array.from( + { length: numTiles }, + (value, index) => index + ); + nimbusPocketStub + .withArgs(NIMBUS_VARIABLE_CONTILE_POSITIONS) + .returns(positionsArray.toString()); +} + +add_setup(async () => { + do_get_profile(); + Services.fog.initializeFOG(); + + let sandbox = sinon.createSandbox(); + sandbox.stub(SearchService.prototype, "init").resolves(); + + const nimbusStub = sandbox.stub(NimbusFeatures.newtab, "getVariable"); + nimbusStub.withArgs(NIMBUS_VARIABLE_CONTILE_ENABLED).returns(true); + + sandbox.spy(Glean.topsites.sponsoredTilesConfigured, "set"); + sandbox.spy(Glean.topsites.sponsoredTilesReceived, "set"); + + // Temporarily setting isInAutomation to false. + // If Cu.isInAutomation is true then the check for Cu.isInAutomation in + // ContileIntegration._readDefaults passes, bypassing Contile, resulting in + // not being able use stubbed values. + if (Cu.isInAutomation) { + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + false + ); + + if (Cu.isInAutomation) { + // This condition is unexpected, because it is enforced at: + // https://searchfox.org/mozilla-central/rev/ea65de7c/js/xpconnect/src/xpcpublic.h#753-759 + throw new Error("Failed to set isInAutomation to false"); + } + } + registerCleanupFunction(() => { + if (!Cu.isInAutomation) { + Services.prefs.setBoolPref( + "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer", + true + ); + + if (!Cu.isInAutomation) { + // This condition is unexpected, because it is enforced at: + // https://searchfox.org/mozilla-central/rev/ea65de7c/js/xpconnect/src/xpcpublic.h#753-759 + throw new Error("Failed to set isInAutomation to true"); + } + } + + sandbox.restore(); + }); +}); + +add_task(async function test_set_contile_tile_to_oversold() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_moz_sale_tile_to_oversold() { + let sandbox = sinon.createSandbox(); + info( + "determineFilteredTilesAndSetToOversold should set moz-sale tile to oversold when_contile tiles are displayed" + ); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2]); + feed._telemetryUtility.setTiles(mozSalesTile); + + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "mozsales title", + provider: "moz-sales", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_contile_tile_to_oversold() { + let sandbox = sinon.createSandbox(); + info( + "determineFilteredTilesAndSetToOversold should set contile tile to oversold when moz-sale tile is displayed" + ); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2]); + feed._telemetryUtility.setTiles(mozSalesTile); + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + label: "MozSales Title", + title: "MozSales Title", + url: "https://mozsale.net", + sponsored_position: 2, + partner: "moz-sales", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + { + advertiser: "mozsales title", + provider: "moz-sales", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_contile_tiles_to_dismissed() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToDismissed(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_all_contile_tiles_to_dismissed() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + let mergedTiles = []; + + feed._telemetryUtility.determineFilteredTilesAndSetToDismissed(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_moz_sales_tiles_to_dismissed() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2]); + feed._telemetryUtility.setTiles(mozSalesTile); + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToDismissed(mergedTiles); + feed._telemetryUtility.finalizeNewtabPingFields(mergedTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "mozsales title", + provider: "moz-sales", + display_position: null, + display_fail_reason: "dismissed", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_tiles_to_dismissed_then_updated() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + feed._telemetryUtility.setSponsoredTilesConfigured(); + + // Step 1: Set initial tiles + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + // Step 2: Set all tiles to dismissed + feed._telemetryUtility.determineFilteredTilesAndSetToDismissed([]); + + let updatedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + + // Step 3: Finalize with the updated list of tiles. + feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_tile_positions_after_updated_list() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + feed._telemetryUtility.setSponsoredTilesConfigured(); + + // Step 1: Set initial tiles + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + // Step 2: Set 1 tile to oversold (brand3) + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + + // Step 3: Finalize with the updated list of tiles. + let updatedTiles = [ + { + url: "https://www.replacement.com", + label: "replacement", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_tile_positions_after_updated_list_all_tiles() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + feed._telemetryUtility.setSponsoredTilesConfigured(); + + // Step 1: Set initial tiles + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + // Step 2: Set 1 tile to oversold (brand3) + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + + // Step 3: Finalize with the updated list of tiles. + let updatedTiles = [ + { + url: "https://www.replacement.com", + label: "replacement", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.replacement2.com", + label: "replacement2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task( + async function test_set_tile_positions_after_no_refresh_no_tiles_changed() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + feed._telemetryUtility.setSponsoredTilesConfigured(); + + // Step 1: Set initial tiles + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + // Step 2: Set 1 tile to oversold (brand3) + let mergedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.determineFilteredTilesAndSetToOversold(mergedTiles); + + // Step 3: Finalize with the updated list of tiles. + let updatedTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + ]; + feed._telemetryUtility.finalizeNewtabPingFields(updatedTiles); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); + } +); + +add_task(async function test_set_contile_tile_to_unresolved() { + let sandbox = sinon.createSandbox(); + let feed = getTopSitesFeedForTest(sandbox); + + // Create the error state, need to bypass existing checks. + feed._telemetryUtility.allSponsoredTiles = { + ampbrand1: { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: "oversold", + }, + ampbrand2: { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: null, + }, + }; + + feed._telemetryUtility._detectErrorConditionAndSetUnresolved(); + + let result = JSON.stringify({ + sponsoredTilesReceived: Object.values( + feed._telemetryUtility.allSponsoredTiles + ), + }); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: null, + display_fail_reason: "unresolved", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: "unresolved", + }, + ], + }; + Assert.equal(result, JSON.stringify(expectedResult)); + sandbox.restore(); +}); + +add_task(async function test_set_position_to_value_gt_3() { + let sandbox = sinon.createSandbox(); + info("Test setTilePositions uses sponsored_position value, not array index."); + let feed = getTopSitesFeedForTest(sandbox); + + feed._telemetryUtility.setSponsoredTilesConfigured(); + feed._telemetryUtility.setTiles([contileTile1, contileTile2, contileTile3]); + + let filteredContileTiles = [ + { + url: "https://www.brand1.com", + label: "brand1", + sponsored_position: 1, + partner: "amp", + }, + { + url: "https://www.brand2.com", + label: "brand2", + sponsored_position: 2, + partner: "amp", + }, + { + url: "https://www.brand3.com", + label: "brand3", + sponsored_position: 6, + partner: "amp", + }, + ]; + + feed._telemetryUtility.determineFilteredTilesAndSetToOversold( + filteredContileTiles + ); + feed._telemetryUtility.finalizeNewtabPingFields(filteredContileTiles); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 6, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_all_tiles_displayed() { + let sandbox = sinon.createSandbox(); + info("if all provided tiles are displayed, the display_fail_reason is null"); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_one_tile_display_fail_reason_to_oversold() { + let sandbox = sinon.createSandbox(); + + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brnad3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_set_one_tile_display_fail_reason_to_dismissed() { + let sandbox = sinon.createSandbox(); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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://foo.com", + image_url: "images/foo.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.brand2.com", + image_url: "images/brnad2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); + +add_task( + async function test_set_one_tile_to_dismissed_and_one_tile_to_oversold() { + let sandbox = sinon.createSandbox(); + let { feed, fetchStub } = prepFeed( + getTopSitesFeedForTest(sandbox), + sandbox + ); + + 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://foo.com", + image_url: "images/foo.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + { + url: "https://www.brand4.com", + image_url: "images/brand4-com.png", + click_url: "https://www.brand4-click.com", + impression_url: "https://www.brand4-impression.com", + name: "brand4", + }, + ], + }), + }); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand4", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); + } +); + +add_task( + async function test_set_one_cached_tile_display_fail_reason_to_dismissed() { + let sandbox = sinon.createSandbox(); + info("confirm the telemetry is valid when using cached tiles."); + + let { feed, fetchStub } = prepFeed( + getTopSitesFeedForTest(sandbox), + sandbox + ); + + const tiles = [ + { + url: "https://www.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + 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", + }, + ]; + + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + fetchStub.resolves({ + status: 304, + }); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 1); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); + } +); + +add_task(async function test_update_tile_count() { + let sandbox = sinon.createSandbox(); + info( + "the tile count should update when topSitesMaxSponsored is updated by Nimbus" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + + // 1. Initially the Nimbus pref is set to 2 tiles + let fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + // 2. Set the Numbus pref to 3, confirm previous count still used. + const nimbusPocketStub = sandbox.stub( + NimbusFeatures.pocketNewtab, + "getVariable" + ); + setNimbusVariablesForNumTiles(nimbusPocketStub, 3); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + // 3. Confirm the new count is applied when data pulled from Contile., 3 tiles displayed + fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 3); + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); + + expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + sandbox.restore(); +}); + +add_task(async function test_update_tile_count_sourced_from_cache() { + let sandbox = sinon.createSandbox(); + + info( + "the tile count should update from cache when topSitesMaxSponsored is updated by Nimbus" + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + const tiles = [ + { + url: "https://www.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ]; + + Services.prefs.setStringPref(CONTILE_CACHE_PREF, JSON.stringify(tiles)); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, 60 * 15); + Services.prefs.setIntPref( + CONTILE_CACHE_LAST_FETCH_PREF, + Math.round(Date.now() / 1000) + ); + + fetchStub.resolves({ + status: 304, + }); + + // 1. Initially the Nimbus pref is set to 2 tiles + // Ensure ContileIntegration._fetchSites is working populate _sites and initilize TelemetryUtility + let fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 3); + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + // 2. Set the Numbus pref to 3, confirm previous count still used. + const nimbusPocketStub = sandbox.stub( + NimbusFeatures.pocketNewtab, + "getVariable" + ); + setNimbusVariablesForNumTiles(nimbusPocketStub, 3); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + // 3. Confirm the new count is applied when data pulled from Contile, 3 tiles displayed + fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 3); + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 3); + + expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 3, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task( + async function test_update_telemetry_fields_if_dismissed_brands_list_is_updated() { + let sandbox = sinon.createSandbox(); + info( + "if the user dismisses a brand, that dismissed tile shoudl be represented in the next ping." + ); + let { feed, fetchStub } = prepFeed( + getTopSitesFeedForTest(sandbox), + sandbox + ); + + 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://foo.com", + image_url: "images/foo.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + let fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar","brand2"]` + ); + + fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 1); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); + } +); + +add_task(async function test_sponsoredTilesReceived_not_set() { + let sandbox = sinon.createSandbox(); + info("sponsoredTilesReceived should be empty if tiles service returns 204"); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + fetchStub.resolves({ ok: true, status: 204 }); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(!fetched); + Assert.ok(!feed._contile.sites.length); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + let expectedResult = { sponsoredTilesReceived: [] }; + + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); + +add_task(async function test_telemetry_data_updates() { + let sandbox = sinon.createSandbox(); + info("sponsoredTilesReceived should update when new tiles received."); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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://foo.com", + image_url: "images/foo.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + 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://foo.com", + image_url: "images/foo.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://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "brand3", + }, + ], + }), + }); + + await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 1); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "bar", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); + +add_task(async function test_reset_telemetry_data() { + let sandbox = sinon.createSandbox(); + info( + "sponsoredTilesReceived should be cleared when no tiles received and cache refreshed." + ); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + 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://foo.com", + image_url: "images/foo.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + { + url: "https://www.brand3.com", + image_url: "images/brand3-com.png", + click_url: "https://www.brand3-click.com", + impression_url: "https://www.brand3-impression.com", + name: "test3", + }, + ], + }), + }); + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + `["foo","bar"]` + ); + + let fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "foo", + provider: "amp", + display_position: null, + display_fail_reason: "dismissed", + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand3", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + + fetchStub.resolves({ ok: true, status: 204 }); + + fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.ok(!feed._contile.sites.length); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + + expectedResult = { sponsoredTilesReceived: [] }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + Services.prefs.clearUserPref(TOP_SITES_BLOCKED_SPONSORS_PREF); + sandbox.restore(); +}); + +add_task(async function test_set_telemetry_for_moz_sales_tiles() { + let sandbox = sinon.createSandbox(); + let { feed, fetchStub } = prepFeed(getTopSitesFeedForTest(sandbox), sandbox); + + sandbox.stub(feed, "fetchDiscoveryStreamSpocs").returns([ + { + label: "MozSales Title", + title: "MozSales Title", + url: "https://mozsale.net", + sponsored_position: 1, + partner: "moz-sales", + }, + ]); + + 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.brand1.com", + image_url: "images/brand1-com.png", + click_url: "https://www.brand1-click.com", + impression_url: "https://www.brand1-impression.com", + name: "brand1", + }, + { + url: "https://www.brand2.com", + image_url: "images/brand2-com.png", + click_url: "https://www.brand2-click.com", + impression_url: "https://www.brand2-impression.com", + name: "brand2", + }, + ], + }), + }); + const fetched = await feed._contile._fetchSites(); + Assert.ok(fetched); + Assert.equal(feed._contile.sites.length, 2); + + await feed._readDefaults(); + await feed.getLinksWithDefaults(false); + + Assert.equal(Glean.topsites.sponsoredTilesConfigured.testGetValue(), 2); + let expectedResult = { + sponsoredTilesReceived: [ + { + advertiser: "brand1", + provider: "amp", + display_position: 1, + display_fail_reason: null, + }, + { + advertiser: "brand2", + provider: "amp", + display_position: 2, + display_fail_reason: null, + }, + { + advertiser: "mozsales title", + provider: "moz-sales", + display_position: null, + display_fail_reason: "oversold", + }, + ], + }; + Assert.equal( + Glean.topsites.sponsoredTilesReceived.testGetValue(), + JSON.stringify(expectedResult) + ); + sandbox.restore(); +}); diff --git a/browser/components/newtab/test/xpcshell/topstories.json b/browser/components/newtab/test/xpcshell/topstories.json new file mode 100644 index 0000000000..7d65fcb0e1 --- /dev/null +++ b/browser/components/newtab/test/xpcshell/topstories.json @@ -0,0 +1,53 @@ +{ + "status": 1, + "settings": { + "spocsPerNewTabs": 0.5, + "domainAffinityParameterSets": { + "default": { + "recencyFactor": 0.5, + "frequencyFactor": 0.5, + "combinedDomainFactor": 0.5, + "perfectFrequencyVisits": 10, + "perfectCombinedDomainScore": 2, + "multiDomainBoost": 0, + "itemScoreFactor": 1 + }, + "fully-personalized": { + "recencyFactor": 0.5, + "frequencyFactor": 0.5, + "combinedDomainFactor": 0.5, + "perfectFrequencyVisits": 10, + "perfectCombinedDomainScore": 2, + "itemScoreFactor": 0.01, + "multiDomainBoost": 0 + } + }, + "timeSegments": [ + { "id": "week", "startTime": 604800, "endTime": 0, "weightPosition": 1 }, + { + "id": "month", + "startTime": 2592000, + "endTime": 604800, + "weightPosition": 0.5 + } + ], + "recsExpireTime": 5400, + "version": "2c2aa06dac65ddb647d8902aaa60263c8e119ff2" + }, + "spocs": [], + "recommendations": [ + { + "id": 53093, + "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==", + "domain": "bbc.com", + "title": "Why vegan junk food may be even worse for your health", + "excerpt": "While we might switch to a plant-based diet with the best intentions, the unseen risks of vegan fast foods might not show up for years.", + "image_src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAA/UlEQVR4nO3RMQ0AMAzAsPIn3d5DsBw2gkiZJWV+B/AyJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQGENiDIkxJMaQmAP4K6zWNUjE4wAAAABJRU5ErkJggg==", + "published_timestamp": "1580277600", + "engagement": "", + "parameter_set": "default", + "domain_affinities": {}, + "item_score": 1 + } + ] +} diff --git a/browser/components/newtab/test/xpcshell/xpcshell.toml b/browser/components/newtab/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..a8470913af --- /dev/null +++ b/browser/components/newtab/test/xpcshell/xpcshell.toml @@ -0,0 +1,34 @@ +[DEFAULT] +firefox-appdir = "browser" +skip-if = ["os == 'android'"] # bug 1730213 +prefs = [ + "browser.startup.homepage.abouthome_cache.enabled=true", + "browser.startup.homepage.abouthome_cache.testing=true", +] + +["test_AboutHomeStartupCacheChild.js"] + +["test_AboutHomeStartupCacheWorker.js"] +support-files = ["topstories.json"] +skip-if = ["socketprocess_networking"] # Bug 1759035 + +["test_AboutNewTab.js"] + +["test_AboutWelcomeAttribution.js"] + +["test_AboutWelcomeTelemetry.js"] + +["test_AboutWelcomeTelemetry_glean.js"] + +["test_HighlightsFeed.js"] + +["test_PlacesFeed.js"] + +["test_Store.js"] + +["test_TelemetryFeed.js"] +support-files = ["../schemas/*.schema.json"] + +["test_TopSitesFeed.js"] + +["test_TopSitesFeed_glean.js"] -- cgit v1.2.3