summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/test/unit/lib
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/test/unit/lib')
-rw-r--r--browser/components/newtab/test/unit/lib/AboutPreferences.test.js429
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStream.test.js576
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js432
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js113
-rw-r--r--browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js161
-rw-r--r--browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js3581
-rw-r--r--browser/components/newtab/test/unit/lib/DownloadsManager.test.js373
-rw-r--r--browser/components/newtab/test/unit/lib/FaviconFeed.test.js233
-rw-r--r--browser/components/newtab/test/unit/lib/FilterAdult.test.js112
-rw-r--r--browser/components/newtab/test/unit/lib/HighlightsFeed.test.js822
-rw-r--r--browser/components/newtab/test/unit/lib/LinksCache.test.js16
-rw-r--r--browser/components/newtab/test/unit/lib/MomentsPageHub.test.js336
-rw-r--r--browser/components/newtab/test/unit/lib/NewTabInit.test.js81
-rw-r--r--browser/components/newtab/test/unit/lib/PersistentCache.test.js142
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js95
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js479
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js356
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js456
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js1543
-rw-r--r--browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js134
-rw-r--r--browser/components/newtab/test/unit/lib/PlacesFeed.test.js1245
-rw-r--r--browser/components/newtab/test/unit/lib/PrefsFeed.test.js357
-rw-r--r--browser/components/newtab/test/unit/lib/RecommendationProvider.test.js162
-rw-r--r--browser/components/newtab/test/unit/lib/Screenshots.test.js209
-rw-r--r--browser/components/newtab/test/unit/lib/SectionsManager.test.js897
-rw-r--r--browser/components/newtab/test/unit/lib/ShortUrl.test.js104
-rw-r--r--browser/components/newtab/test/unit/lib/SiteClassifier.test.js252
-rw-r--r--browser/components/newtab/test/unit/lib/Store.test.js305
-rw-r--r--browser/components/newtab/test/unit/lib/SystemTickFeed.test.js76
-rw-r--r--browser/components/newtab/test/unit/lib/TelemetryFeed.test.js2606
-rw-r--r--browser/components/newtab/test/unit/lib/TippyTopProvider.test.js121
-rw-r--r--browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js649
-rw-r--r--browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js934
-rw-r--r--browser/components/newtab/test/unit/lib/TopSitesFeed.test.js3020
-rw-r--r--browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js1903
-rw-r--r--browser/components/newtab/test/unit/lib/UTEventReporting.test.js115
36 files changed, 23425 insertions, 0 deletions
diff --git a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
new file mode 100644
index 0000000000..f355c6f0ab
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js
@@ -0,0 +1,429 @@
+/* global Services */
+import {
+ AboutPreferences,
+ PREFERENCES_LOADED_EVENT,
+} from "lib/AboutPreferences.jsm";
+import {
+ actionTypes as at,
+ actionCreators as ac,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("AboutPreferences Feed", () => {
+ let globals;
+ let sandbox;
+ let Sections;
+ let DiscoveryStream;
+ let instance;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ Sections = [];
+ DiscoveryStream = { config: { enabled: false } };
+ instance = new AboutPreferences();
+ instance.store = {
+ dispatch: sandbox.stub(),
+ getState: () => ({ Sections, DiscoveryStream }),
+ };
+ globals.set("NimbusFeatures", {
+ newtab: { getAllVariables: sandbox.stub() },
+ });
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#onAction", () => {
+ it("should call .init() on an INIT action", () => {
+ const stub = sandbox.stub(instance, "init");
+
+ instance.onAction({ type: at.INIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .uninit() on an UNINIT action", () => {
+ const stub = sandbox.stub(instance, "uninit");
+
+ instance.onAction({ type: at.UNINIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .openPreferences on SETTINGS_OPEN", () => {
+ const action = {
+ type: at.SETTINGS_OPEN,
+ _target: { browser: { ownerGlobal: { openPreferences: sinon.spy() } } },
+ };
+ instance.onAction(action);
+ assert.calledOnce(action._target.browser.ownerGlobal.openPreferences);
+ });
+ it("should call .BrowserOpenAddonsMgr with the extension id on OPEN_WEBEXT_SETTINGS", () => {
+ const action = {
+ type: at.OPEN_WEBEXT_SETTINGS,
+ data: "foo",
+ _target: {
+ browser: { ownerGlobal: { BrowserOpenAddonsMgr: sinon.spy() } },
+ },
+ };
+ instance.onAction(action);
+ assert.calledWith(
+ action._target.browser.ownerGlobal.BrowserOpenAddonsMgr,
+ "addons://detail/foo"
+ );
+ });
+ });
+ describe("#observe", () => {
+ it("should watch for about:preferences loading", () => {
+ sandbox.stub(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledOnce(Services.obs.addObserver);
+ assert.calledWith(
+ Services.obs.addObserver,
+ instance,
+ PREFERENCES_LOADED_EVENT
+ );
+ });
+ it("should stop watching on uninit", () => {
+ sandbox.stub(Services.obs, "removeObserver");
+
+ instance.uninit();
+
+ assert.calledOnce(Services.obs.removeObserver);
+ assert.calledWith(
+ Services.obs.removeObserver,
+ instance,
+ PREFERENCES_LOADED_EVENT
+ );
+ });
+ it("should try to render on event", async () => {
+ const stub = sandbox.stub(instance, "renderPreferences");
+ Sections.push({});
+
+ await instance.observe(window, PREFERENCES_LOADED_EVENT);
+
+ assert.calledOnce(stub);
+ assert.equal(stub.firstCall.args[0], window);
+ assert.include(stub.firstCall.args[1], Sections[0]);
+ });
+ it("Hide topstories rows select in sections if discovery stream is enabled", async () => {
+ const stub = sandbox.stub(instance, "renderPreferences");
+
+ Sections.push({
+ rowsPref: "row_pref",
+ maxRows: 3,
+ pref: { descString: "foo" },
+ learnMore: { link: "https://foo.com" },
+ id: "topstories",
+ });
+ DiscoveryStream = { config: { enabled: true } };
+
+ await instance.observe(window, PREFERENCES_LOADED_EVENT);
+
+ assert.calledOnce(stub);
+ const [, structure] = stub.firstCall.args;
+ assert.equal(structure[0].id, "search");
+ assert.equal(structure[1].id, "topsites");
+ assert.equal(structure[2].id, "topstories");
+ assert.isEmpty(structure[2].rowsPref);
+ });
+ });
+ describe("#renderPreferences", () => {
+ let node;
+ let prefStructure;
+ let Preferences;
+ let gHomePane;
+ const testRender = () =>
+ instance.renderPreferences(
+ {
+ document: {
+ createXULElement: sandbox.stub().returns(node),
+ l10n: {
+ setAttributes(el, id, args) {
+ el.setAttribute("data-l10n-id", id);
+ el.setAttribute("data-l10n-args", JSON.stringify(args));
+ },
+ },
+ createProcessingInstruction: sandbox.stub(),
+ createElementNS: sandbox.stub().callsFake((NS, el) => node),
+ getElementById: sandbox.stub().returns(node),
+ insertBefore: sandbox.stub().returnsArg(0),
+ querySelector: sandbox
+ .stub()
+ .returns({ appendChild: sandbox.stub() }),
+ },
+ Preferences,
+ gHomePane,
+ },
+ prefStructure,
+ DiscoveryStream.config
+ );
+ beforeEach(() => {
+ node = {
+ appendChild: sandbox.stub().returnsArg(0),
+ addEventListener: sandbox.stub(),
+ classList: { add: sandbox.stub(), remove: sandbox.stub() },
+ cloneNode: sandbox.stub().returnsThis(),
+ insertAdjacentElement: sandbox.stub().returnsArg(1),
+ setAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ style: {},
+ };
+ prefStructure = [];
+ Preferences = {
+ add: sandbox.stub(),
+ get: sandbox.stub().returns({
+ on: sandbox.stub(),
+ }),
+ };
+ gHomePane = { toggleRestoreDefaultsBtn: sandbox.stub() };
+ });
+ describe("#getString", () => {
+ it("should not fail if titleString is not provided", () => {
+ prefStructure = [{ pref: {} }];
+
+ testRender();
+ assert.calledWith(
+ node.setAttribute,
+ "data-l10n-id",
+ sinon.match.typeOf("undefined")
+ );
+ });
+ it("should return the string id if titleString is just a string", () => {
+ const titleString = "foo";
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ it("should set id and args if titleString is an object with id and values", () => {
+ const titleString = { id: "foo", values: { provider: "bar" } };
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString.id);
+ assert.calledWith(
+ node.setAttribute,
+ "data-l10n-args",
+ JSON.stringify(titleString.values)
+ );
+ });
+ });
+ describe("#linkPref", () => {
+ it("should add a pref to the global", () => {
+ prefStructure = [{ pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledOnce(Preferences.add);
+ });
+ it("should skip adding if not shown", () => {
+ prefStructure = [{ shouldHidePref: true }];
+
+ testRender();
+
+ assert.notCalled(Preferences.add);
+ });
+ });
+ describe("pref icon", () => {
+ it("should default to webextension icon", () => {
+ prefStructure = [{ pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(
+ node.setAttribute,
+ "src",
+ "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg"
+ );
+ });
+ it("should use desired glyph icon", () => {
+ prefStructure = [{ icon: "mail", pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(
+ node.setAttribute,
+ "src",
+ "chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg"
+ );
+ });
+ it("should use specified chrome icon", () => {
+ const icon = "chrome://the/icon.svg";
+ prefStructure = [{ icon, pref: { feed: "feed" } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "src", icon);
+ });
+ });
+ describe("title line", () => {
+ it("should render a title", () => {
+ const titleString = "the_title";
+ prefStructure = [{ pref: { titleString } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ });
+ describe("top stories", () => {
+ const href = "https://disclaimer/";
+ const eventSource = "https://disclaimer/";
+ beforeEach(() => {
+ prefStructure = [
+ {
+ id: "topstories",
+ pref: { feed: "feed", learnMore: { link: { href } } },
+ eventSource,
+ },
+ ];
+ });
+ it("should add a link for top stories", () => {
+ testRender();
+ assert.calledWith(node.setAttribute, "href", href);
+ });
+ it("should setup a user event for top stories eventSource", () => {
+ sinon.spy(instance, "setupUserEvent");
+ testRender();
+ assert.calledWith(node.addEventListener, "command");
+ assert.calledWith(instance.setupUserEvent, node, eventSource);
+ });
+ it("should setup a user event for top stories nested pref eventSource", () => {
+ sinon.spy(instance, "setupUserEvent");
+ prefStructure = [
+ {
+ id: "topstories",
+ pref: {
+ feed: "feed",
+ learnMore: { link: { href } },
+ nestedPrefs: [
+ {
+ name: "showSponsored",
+ titleString:
+ "home-prefs-recommended-by-option-sponsored-stories",
+ icon: "icon-info",
+ eventSource: "POCKET_SPOCS",
+ },
+ ],
+ },
+ },
+ ];
+ testRender();
+ assert.calledWith(node.addEventListener, "command");
+ assert.calledWith(instance.setupUserEvent, node, "POCKET_SPOCS");
+ });
+ it("should fire store dispatch with onCommand", () => {
+ const element = {
+ addEventListener: (command, action) => {
+ // Trigger the action right away because we only care about testing the action here.
+ action({ target: { checked: true } });
+ },
+ };
+ instance.setupUserEvent(element, eventSource);
+ assert.calledWith(
+ instance.store.dispatch,
+ ac.UserEvent({
+ event: "PREF_CHANGED",
+ source: eventSource,
+ value: { menu_source: "ABOUT_PREFERENCES", status: true },
+ })
+ );
+ });
+ });
+ describe("description line", () => {
+ it("should render a description", () => {
+ const descString = "the_desc";
+ prefStructure = [{ pref: { descString } }];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", descString);
+ });
+ it("should render rows dropdown with appropriate number", () => {
+ prefStructure = [
+ { rowsPref: "row_pref", maxRows: 3, pref: { descString: "foo" } },
+ ];
+
+ testRender();
+
+ assert.calledWith(node.setAttribute, "value", 1);
+ assert.calledWith(node.setAttribute, "value", 2);
+ assert.calledWith(node.setAttribute, "value", 3);
+ });
+ });
+ describe("nested prefs", () => {
+ const titleString = "im_nested";
+ beforeEach(() => {
+ prefStructure = [{ pref: { nestedPrefs: [{ titleString }] } }];
+ });
+ it("should render a nested pref", () => {
+ testRender();
+
+ assert.calledWith(node.setAttribute, "data-l10n-id", titleString);
+ });
+ it("should set node hidden to true", () => {
+ prefStructure[0].pref.nestedPrefs[0].hidden = true;
+
+ testRender();
+
+ assert.isTrue(node.hidden);
+ });
+ it("should add a change event", () => {
+ testRender();
+
+ assert.calledOnce(Preferences.get().on);
+ assert.calledWith(Preferences.get().on, "change");
+ });
+ it("should default node disabled to false", async () => {
+ Preferences.get = sandbox.stub().returns({
+ on: sandbox.stub(),
+ _value: true,
+ });
+
+ testRender();
+
+ assert.isFalse(node.disabled);
+ });
+ it("should default node disabled to true", async () => {
+ testRender();
+
+ assert.isTrue(node.disabled);
+ });
+ it("should set node disabled to true", async () => {
+ const pref = {
+ on: sandbox.stub(),
+ _value: true,
+ };
+ Preferences.get = sandbox.stub().returns(pref);
+
+ testRender();
+ pref._value = !pref._value;
+ await Preferences.get().on.firstCall.args[1]();
+
+ assert.isTrue(node.disabled);
+ });
+ it("should set node disabled to false", async () => {
+ const pref = {
+ on: sandbox.stub(),
+ _value: false,
+ };
+ Preferences.get = sandbox.stub().returns(pref);
+
+ testRender();
+ pref._value = !pref._value;
+ await Preferences.get().on.firstCall.args[1]();
+
+ assert.isFalse(node.disabled);
+ });
+ });
+ describe("restore defaults btn", () => {
+ it("should call toggleRestoreDefaultsBtn", () => {
+ testRender();
+
+ assert.calledOnce(gHomePane.toggleRestoreDefaultsBtn);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStream.test.js b/browser/components/newtab/test/unit/lib/ActivityStream.test.js
new file mode 100644
index 0000000000..47880d00bc
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js
@@ -0,0 +1,576 @@
+import { CONTENT_MESSAGE_TYPE } from "common/Actions.sys.mjs";
+import { ActivityStream, PREFS_CONFIG } from "lib/ActivityStream.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+import { DEFAULT_SITES } from "lib/DefaultSites.sys.mjs";
+import { AboutPreferences } from "lib/AboutPreferences.jsm";
+import { DefaultPrefs } from "lib/ActivityStreamPrefs.jsm";
+import { NewTabInit } from "lib/NewTabInit.jsm";
+import { SectionsFeed } from "lib/SectionsManager.jsm";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { PlacesFeed } from "lib/PlacesFeed.jsm";
+import { PrefsFeed } from "lib/PrefsFeed.jsm";
+import { SystemTickFeed } from "lib/SystemTickFeed.jsm";
+import { TelemetryFeed } from "lib/TelemetryFeed.jsm";
+import { FaviconFeed } from "lib/FaviconFeed.jsm";
+import { TopSitesFeed } from "lib/TopSitesFeed.jsm";
+import { TopStoriesFeed } from "lib/TopStoriesFeed.jsm";
+import { HighlightsFeed } from "lib/HighlightsFeed.jsm";
+import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm";
+
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+import { DownloadsManager } from "lib/DownloadsManager.jsm";
+
+describe("ActivityStream", () => {
+ let sandbox;
+ let as;
+ function FakeStore() {
+ return { init: () => {}, uninit: () => {}, feeds: { get: () => {} } };
+ }
+
+ let globals;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set({
+ Store: FakeStore,
+
+ DEFAULT_SITES,
+ AboutPreferences,
+ DefaultPrefs,
+ NewTabInit,
+ SectionsFeed,
+ RecommendationProvider,
+ PlacesFeed,
+ PrefsFeed,
+ SystemTickFeed,
+ TelemetryFeed,
+ FaviconFeed,
+ TopSitesFeed,
+ TopStoriesFeed,
+ HighlightsFeed,
+ DiscoveryStreamFeed,
+
+ LinksCache,
+ PersistentCache,
+ DownloadsManager,
+ });
+
+ as = new ActivityStream();
+ sandbox = sinon.createSandbox();
+ sandbox.stub(as.store, "init");
+ sandbox.stub(as.store, "uninit");
+ sandbox.stub(as._defaultPrefs, "init");
+ PREFS_CONFIG.get("feeds.system.topstories").value = undefined;
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should exist", () => {
+ assert.ok(ActivityStream);
+ });
+ it("should initialize with .initialized=false", () => {
+ assert.isFalse(as.initialized, ".initialized");
+ });
+ describe("#init", () => {
+ beforeEach(() => {
+ as.init();
+ });
+ it("should initialize default prefs", () => {
+ assert.calledOnce(as._defaultPrefs.init);
+ });
+ it("should set .initialized to true", () => {
+ assert.isTrue(as.initialized, ".initialized");
+ });
+ it("should call .store.init", () => {
+ assert.calledOnce(as.store.init);
+ });
+ it("should pass to Store an INIT event for content", () => {
+ as.init();
+
+ const [, action] = as.store.init.firstCall.args;
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ it("should pass to Store an UNINIT event", () => {
+ as.init();
+
+ const [, , action] = as.store.init.firstCall.args;
+ assert.equal(action.type, "UNINIT");
+ });
+ it("should clear old default discoverystream config pref", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .returns(
+ `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}`
+ );
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+
+ as.init();
+
+ assert.calledWith(
+ global.Services.prefs.clearUserPref,
+ "browser.newtabpage.activity-stream.discoverystream.config"
+ );
+ });
+ it("should call addObserver for the app locales", () => {
+ sandbox.stub(global.Services.obs, "addObserver");
+ as.init();
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ as,
+ "intl:app-locales-changed"
+ );
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(() => {
+ as.init();
+ as.uninit();
+ });
+ it("should set .initialized to false", () => {
+ assert.isFalse(as.initialized, ".initialized");
+ });
+ it("should call .store.uninit", () => {
+ assert.calledOnce(as.store.uninit);
+ });
+ it("should call removeObserver for the region", () => {
+ sandbox.stub(global.Services.obs, "removeObserver");
+ as.geo = "";
+ as.uninit();
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ as,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should call removeObserver for the app locales", () => {
+ sandbox.stub(global.Services.obs, "removeObserver");
+ as.uninit();
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ as,
+ "intl:app-locales-changed"
+ );
+ });
+ });
+ describe("#observe", () => {
+ it("should call _updateDynamicPrefs from observe", () => {
+ sandbox.stub(as, "_updateDynamicPrefs");
+ as.observe(undefined, global.Region.REGION_TOPIC);
+ assert.calledOnce(as._updateDynamicPrefs);
+ });
+ });
+ describe("feeds", () => {
+ it("should create a NewTabInit feed", () => {
+ const feed = as.feeds.get("feeds.newtabinit")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Places feed", () => {
+ const feed = as.feeds.get("feeds.places")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a TopSites feed", () => {
+ const feed = as.feeds.get("feeds.system.topsites")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Telemetry feed", () => {
+ const feed = as.feeds.get("feeds.telemetry")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Prefs feed", () => {
+ const feed = as.feeds.get("feeds.prefs")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a HighlightsFeed feed", () => {
+ const feed = as.feeds.get("feeds.section.highlights")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a TopStoriesFeed feed", () => {
+ const feed = as.feeds.get("feeds.system.topstories")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a AboutPreferences feed", () => {
+ const feed = as.feeds.get("feeds.aboutpreferences")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a SectionsFeed", () => {
+ const feed = as.feeds.get("feeds.sections")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a SystemTick feed", () => {
+ const feed = as.feeds.get("feeds.systemtick")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a Favicon feed", () => {
+ const feed = as.feeds.get("feeds.favicon")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a RecommendationProvider feed", () => {
+ const feed = as.feeds.get("feeds.recommendationprovider")();
+ assert.ok(feed, "feed should exist");
+ });
+ it("should create a DiscoveryStreamFeed feed", () => {
+ const feed = as.feeds.get("feeds.discoverystreamfeed")();
+ assert.ok(feed, "feed should exist");
+ });
+ });
+ describe("_migratePref", () => {
+ it("should migrate a pref if the user has set a custom value", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ sandbox.stub(global.Services.prefs, "getIntPref").returns(10);
+ as._migratePref("oldPrefName", result => assert.equal(10, result));
+ });
+ it("should not migrate a pref if the user has not set a custom value", () => {
+ // we bailed out early so we don't check the pref type later
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(false);
+ sandbox.stub(global.Services.prefs, "getPrefType");
+ as._migratePref("oldPrefName");
+ assert.notCalled(global.Services.prefs.getPrefType);
+ });
+ it("should use the proper pref getter for each type", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+
+ // Integer
+ sandbox.stub(global.Services.prefs, "getIntPref");
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getIntPref, "oldPrefName");
+
+ // Boolean
+ sandbox.stub(global.Services.prefs, "getBoolPref");
+ global.Services.prefs.getPrefType.returns("boolean");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getBoolPref, "oldPrefName");
+
+ // String
+ sandbox.stub(global.Services.prefs, "getStringPref");
+ global.Services.prefs.getPrefType.returns("string");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.getStringPref, "oldPrefName");
+ });
+ it("should clear the old pref after setting the new one", () => {
+ sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true);
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+ sandbox.stub(global.Services.prefs, "getPrefType").returns("integer");
+ as._migratePref("oldPrefName", () => {});
+ assert.calledWith(global.Services.prefs.clearUserPref, "oldPrefName");
+ });
+ });
+ describe("discoverystream.region-basic-layout config", () => {
+ let getStringPrefStub;
+ beforeEach(() => {
+ getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ sandbox.stub(global.Region, "home").get(() => "CA");
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-CA");
+ });
+ it("should enable 7 row layout pref if no basic config is set and no geo is set", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("");
+ sandbox.stub(global.Region, "home").get(() => "");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ it("should enable 1 row layout pref based on region layout pref", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("CA");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ it("should enable 7 row layout pref based on region layout pref", () => {
+ getStringPrefStub
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-basic-config"
+ )
+ .returns("");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(
+ PREFS_CONFIG.get("discoverystream.region-basic-layout").value
+ );
+ });
+ });
+ describe("_updateDynamicPrefs topstories default value", () => {
+ let getVariableStub;
+ let getBoolPrefStub;
+ let appLocaleAsBCP47Stub;
+ beforeEach(() => {
+ getVariableStub = sandbox.stub(
+ global.NimbusFeatures.pocketNewtab,
+ "getVariable"
+ );
+ appLocaleAsBCP47Stub = sandbox.stub(
+ global.Services.locale,
+ "appLocaleAsBCP47"
+ );
+
+ getBoolPrefStub = sandbox.stub(global.Services.prefs, "getBoolPref");
+ getBoolPrefStub
+ .withArgs("browser.newtabpage.activity-stream.feeds.section.topstories")
+ .returns(true);
+
+ appLocaleAsBCP47Stub.get(() => "en-US");
+
+ sandbox.stub(global.Region, "home").get(() => "US");
+
+ getVariableStub.withArgs("regionStoriesConfig").returns("US,CA");
+ });
+ it("should be false with no geo/locale", () => {
+ appLocaleAsBCP47Stub.get(() => "");
+ sandbox.stub(global.Region, "home").get(() => "");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with no geo but an allowed locale", () => {
+ appLocaleAsBCP47Stub.get(() => "");
+ sandbox.stub(global.Region, "home").get(() => "");
+ appLocaleAsBCP47Stub.get(() => "en-US");
+ getVariableStub
+ .withArgs("localeListConfig")
+ .returns("en-US,en-CA,en-GB")
+ // We only have this pref set to trigger a close to real situation.
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-block"
+ )
+ .returns("FR");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with unexpected geo", () => {
+ sandbox.stub(global.Region, "home").get(() => "NOGEO");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false with expected geo and unexpected locale", () => {
+ appLocaleAsBCP47Stub.get(() => "no-LOCALE");
+
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with expected geo and locale", () => {
+ as._updateDynamicPrefs();
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be false after expected geo and locale then unexpected", () => {
+ sandbox
+ .stub(global.Region, "home")
+ .onFirstCall()
+ .get(() => "US")
+ .onSecondCall()
+ .get(() => "NOGEO");
+
+ as._updateDynamicPrefs();
+ as._updateDynamicPrefs();
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with updated pref change", () => {
+ appLocaleAsBCP47Stub.get(() => "en-GB");
+ sandbox.stub(global.Region, "home").get(() => "GB");
+ getVariableStub.withArgs("regionStoriesConfig").returns("GB");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should be true with allowed locale in non US region", () => {
+ appLocaleAsBCP47Stub.get(() => "en-CA");
+ sandbox.stub(global.Region, "home").get(() => "DE");
+ getVariableStub.withArgs("localeListConfig").returns("en-US,en-CA,en-GB");
+
+ as._updateDynamicPrefs();
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ });
+ describe("_updateDynamicPrefs topstories delayed default value", () => {
+ let clock;
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+
+ // Have addObserver cause prefHasUserValue to now return true then observe
+ sandbox
+ .stub(global.Services.obs, "addObserver")
+ .callsFake((pref, obs) => {
+ setTimeout(() => {
+ Services.obs.notifyObservers("US", "browser-region-updated");
+ });
+ });
+ });
+ afterEach(() => clock.restore());
+
+ it("should set false with unexpected geo", () => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs("browser.search.region")
+ .returns("NOGEO");
+
+ as._updateDynamicPrefs();
+
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should set true with expected geo and locale", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getVariable")
+ .withArgs("regionStoriesConfig")
+ .returns("US");
+
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should not change default even with expected geo and locale", () => {
+ as._defaultPrefs.set("feeds.system.topstories", false);
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+ )
+ .returns("US");
+
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ it("should set false with geo blocked", () => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-config"
+ )
+ .returns("US")
+ .withArgs(
+ "browser.newtabpage.activity-stream.discoverystream.region-stories-block"
+ )
+ .returns("US");
+
+ sandbox.stub(global.Services.prefs, "getBoolPref").returns(true);
+ sandbox
+ .stub(global.Services.locale, "appLocaleAsBCP47")
+ .get(() => "en-US");
+
+ as._updateDynamicPrefs();
+ clock.tick(1);
+
+ assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value);
+ });
+ });
+ describe("telemetry reporting on init failure", () => {
+ it("should send a ping on init error", () => {
+ as = new ActivityStream();
+ const telemetry = { handleUndesiredEvent: sandbox.spy() };
+ sandbox.stub(as.store, "init").throws();
+ sandbox.stub(as.store.feeds, "get").returns(telemetry);
+ try {
+ as.init();
+ } catch (e) {}
+ assert.calledOnce(telemetry.handleUndesiredEvent);
+ });
+ });
+
+ describe("searchs shortcuts shouldPin pref", () => {
+ const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF =
+ "improvesearch.topSiteSearchShortcuts.searchEngines";
+ let stub;
+
+ beforeEach(() => {
+ stub = sandbox.stub(global.Region, "home");
+ });
+
+ it("should be an empty string when no geo is available", () => {
+ stub.get(() => "");
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ ""
+ );
+ });
+
+ it("should be 'baidu' in China", () => {
+ stub.get(() => "CN");
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "baidu"
+ );
+ });
+
+ it("should be 'yandex' in Russia, Belarus, Kazakhstan, and Turkey", () => {
+ const geos = ["BY", "KZ", "RU", "TR"];
+ for (const geo of geos) {
+ stub.get(() => geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "yandex"
+ );
+ }
+ });
+
+ it("should be 'google,amazon' in Germany, France, the UK, Japan, Italy, and the US", () => {
+ const geos = ["DE", "FR", "GB", "IT", "JP", "US"];
+ for (const geo of geos) {
+ stub.returns(geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "google,amazon"
+ );
+ }
+ });
+
+ it("should be 'google' elsewhere", () => {
+ // A selection of other geos
+ const geos = ["BR", "CA", "ES", "ID", "IN"];
+ for (const geo of geos) {
+ stub.get(() => geo);
+ as._updateDynamicPrefs();
+ assert.equal(
+ PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value,
+ "google"
+ );
+ }
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js
new file mode 100644
index 0000000000..b6aeacead2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js
@@ -0,0 +1,432 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import {
+ ActivityStreamMessageChannel,
+ DEFAULT_OPTIONS,
+} from "lib/ActivityStreamMessageChannel.jsm";
+import { addNumberReducer, GlobalOverrider } from "test/unit/utils";
+import { applyMiddleware, createStore } from "redux";
+
+const OPTIONS = [
+ "pageURL, outgoingMessageName",
+ "incomingMessageName",
+ "dispatch",
+];
+
+// Create an object containing details about a tab as expected within
+// the loaded tabs map in ActivityStreamMessageChannel.jsm.
+function getTabDetails(portID, url = "about:newtab", extraArgs = {}) {
+ let actor = {
+ portID,
+ sendAsyncMessage: sinon.spy(),
+ };
+ let browser = {
+ getAttribute: () => (extraArgs.preloaded ? "preloaded" : ""),
+ ownerGlobal: {},
+ };
+ let browsingContext = {
+ top: {
+ embedderElement: browser,
+ },
+ };
+
+ let data = {
+ data: {
+ actor,
+ browser,
+ browsingContext,
+ portID,
+ url,
+ },
+ target: {
+ browsingContext,
+ },
+ };
+
+ if (extraArgs.loaded) {
+ data.data.loaded = extraArgs.loaded;
+ }
+ if (extraArgs.simulated) {
+ data.data.simulated = extraArgs.simulated;
+ }
+
+ return data;
+}
+
+describe("ActivityStreamMessageChannel", () => {
+ let globals;
+ let dispatch;
+ let mm;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set("AboutNewTab", {
+ reset: globals.sandbox.spy(),
+ });
+ globals.set("AboutHomeStartupCache", { onPreloadedNewTabMessage() {} });
+ dispatch = globals.sandbox.spy();
+ mm = new ActivityStreamMessageChannel({ dispatch });
+
+ assert.ok(mm.loadedTabs, []);
+
+ let loadedTabs = new Map();
+ let sandbox = sinon.createSandbox();
+ sandbox.stub(mm, "loadedTabs").get(() => loadedTabs);
+ });
+
+ afterEach(() => globals.restore());
+
+ describe("portID validation", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.spy(global.console, "error");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should log errors for an invalid portID", () => {
+ mm.validatePortID({});
+ mm.validatePortID({});
+ mm.validatePortID({});
+
+ assert.equal(global.console.error.callCount, 3);
+ });
+ });
+
+ it("should exist", () => {
+ assert.ok(ActivityStreamMessageChannel);
+ });
+ it("should apply default options", () => {
+ mm = new ActivityStreamMessageChannel();
+ OPTIONS.forEach(o => assert.equal(mm[o], DEFAULT_OPTIONS[o], o));
+ });
+ it("should add options", () => {
+ const options = {
+ dispatch: () => {},
+ pageURL: "FOO.html",
+ outgoingMessageName: "OUT",
+ incomingMessageName: "IN",
+ };
+ mm = new ActivityStreamMessageChannel(options);
+ OPTIONS.forEach(o => assert.equal(mm[o], options[o], o));
+ });
+ it("should throw an error if no dispatcher was provided", () => {
+ mm = new ActivityStreamMessageChannel();
+ assert.throws(() => mm.dispatch({ type: "FOO" }));
+ });
+ describe("Creating/destroying the channel", () => {
+ describe("#simulateMessagesForExistingTabs", () => {
+ beforeEach(() => {
+ sinon.stub(mm, "onActionFromContent");
+ });
+ it("should simulate init for existing ports", () => {
+ let msg1 = getTabDetails("inited", "about:monkeys", {
+ simulated: true,
+ });
+ mm.loadedTabs.set(msg1.data.browser, msg1.data);
+
+ let msg2 = getTabDetails("loaded", "about:sheep", {
+ simulated: true,
+ });
+ mm.loadedTabs.set(msg2.data.browser, msg2.data);
+
+ mm.simulateMessagesForExistingTabs();
+
+ assert.calledWith(mm.onActionFromContent.firstCall, {
+ type: at.NEW_TAB_INIT,
+ data: msg1.data,
+ });
+ assert.calledWith(mm.onActionFromContent.secondCall, {
+ type: at.NEW_TAB_INIT,
+ data: msg2.data,
+ });
+ });
+ it("should simulate load for loaded ports", () => {
+ let msg3 = getTabDetails("foo", null, {
+ preloaded: true,
+ loaded: true,
+ });
+ mm.loadedTabs.set(msg3.data.browser, msg3.data);
+
+ mm.simulateMessagesForExistingTabs();
+
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_LOAD },
+ "foo"
+ );
+ });
+ it("should set renderLayers on preloaded browsers after load", () => {
+ let msg4 = getTabDetails("foo", null, {
+ preloaded: true,
+ loaded: true,
+ });
+ msg4.data.browser.ownerGlobal = {
+ STATE_MAXIMIZED: 1,
+ STATE_MINIMIZED: 2,
+ STATE_NORMAL: 3,
+ STATE_FULLSCREEN: 4,
+ windowState: 3,
+ isFullyOccluded: false,
+ };
+ mm.loadedTabs.set(msg4.data.browser, msg4.data);
+ mm.simulateMessagesForExistingTabs();
+ assert.equal(msg4.data.browser.renderLayers, true);
+ });
+ });
+ });
+ describe("Message handling", () => {
+ describe("#getTargetById", () => {
+ it("should get an id if it exists", () => {
+ let msg = getTabDetails("foo:1");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getTargetById("foo:1"), msg.data.actor);
+ });
+ it("should return null if the target doesn't exist", () => {
+ let msg = getTabDetails("foo:2");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getTargetById("bar:3"), null);
+ });
+ });
+ describe("#getPreloadedActors", () => {
+ it("should get a preloaded actor if it exists", () => {
+ let msg = getTabDetails("foo:3", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors()[0].portID, "foo:3");
+ });
+ it("should get all the preloaded actors across windows if they exist", () => {
+ let msg = getTabDetails("foo:4a", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ msg = getTabDetails("foo:4b", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors().length, 2);
+ });
+ it("should return null if there is no preloaded actor", () => {
+ let msg = getTabDetails("foo:5");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ assert.equal(mm.getPreloadedActors(), null);
+ });
+ });
+ describe("#onNewTabInit", () => {
+ it("should dispatch a NEW_TAB_INIT action", () => {
+ let msg = getTabDetails("foo", "about:monkeys");
+ sinon.stub(mm, "onActionFromContent");
+
+ mm.onNewTabInit(msg, msg.data);
+
+ assert.calledWith(mm.onActionFromContent, {
+ type: at.NEW_TAB_INIT,
+ data: msg.data,
+ });
+ });
+ });
+ describe("#onNewTabLoad", () => {
+ it("should dispatch a NEW_TAB_LOAD action", () => {
+ let msg = getTabDetails("foo", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ sinon.stub(mm, "onActionFromContent");
+ mm.onNewTabLoad({ target: msg.target }, msg.data);
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_LOAD },
+ "foo"
+ );
+ });
+ });
+ describe("#onNewTabUnload", () => {
+ it("should dispatch a NEW_TAB_UNLOAD action", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ sinon.stub(mm, "onActionFromContent");
+ mm.onNewTabUnload({ target: msg.target }, msg.data);
+ assert.calledWith(
+ mm.onActionFromContent,
+ { type: at.NEW_TAB_UNLOAD },
+ "foo"
+ );
+ });
+ });
+ describe("#onMessage", () => {
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ sandbox.spy(global.console, "error");
+ });
+ afterEach(() => sandbox.restore());
+ it("return early when tab details are not present", () => {
+ let msg = getTabDetails("foo");
+ sinon.stub(mm, "onActionFromContent");
+ mm.onMessage(msg, msg.data);
+ assert.notCalled(mm.onActionFromContent);
+ });
+ it("should report an error if the msg.data is missing", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ let tabDetails = msg.data;
+ delete msg.data;
+ mm.onMessage(msg, tabDetails);
+ assert.calledOnce(global.console.error);
+ });
+ it("should report an error if the msg.data.type is missing", () => {
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ msg.data = "foo";
+ mm.onMessage(msg, msg.data);
+ assert.calledOnce(global.console.error);
+ });
+ it("should call onActionFromContent", () => {
+ sinon.stub(mm, "onActionFromContent");
+ let msg = getTabDetails("foo");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ let action = {
+ data: { data: {}, type: "FOO" },
+ target: msg.target,
+ };
+ const expectedAction = {
+ type: action.data.type,
+ data: action.data.data,
+ _target: { browser: msg.data.browser },
+ };
+ mm.onMessage(action, msg.data);
+ assert.calledWith(mm.onActionFromContent, expectedAction, "foo");
+ });
+ });
+ });
+ describe("Sending and broadcasting", () => {
+ describe("#send", () => {
+ it("should send a message on the right port", () => {
+ let msg = getTabDetails("foo:6");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:6");
+ mm.send(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ it("should not throw if the target isn't around", () => {
+ // port is not added to the channel
+ const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:7");
+
+ assert.doesNotThrow(() => mm.send(action));
+ });
+ });
+ describe("#broadcast", () => {
+ it("should send a message on the channel", () => {
+ let msg = getTabDetails("foo:8");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.BroadcastToContent({ type: "HELLO" });
+ mm.broadcast(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ });
+ describe("#preloaded browser", () => {
+ it("should send the message to the preloaded browser if there's data and a preloaded browser exists", () => {
+ let msg = getTabDetails("foo:9", null, { preloaded: true });
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToPreloaded({ type: "HELLO", data: 10 });
+ mm.sendToPreloaded(action);
+ assert.calledWith(
+ msg.data.actor.sendAsyncMessage,
+ DEFAULT_OPTIONS.outgoingMessageName,
+ action
+ );
+ });
+ it("should send the message to all the preloaded browsers if there's data and they exist", () => {
+ let msg1 = getTabDetails("foo:10a", null, { preloaded: true });
+ mm.loadedTabs.set(msg1.data.browser, msg1.data);
+
+ let msg2 = getTabDetails("foo:10b", null, { preloaded: true });
+ mm.loadedTabs.set(msg2.data.browser, msg2.data);
+
+ mm.sendToPreloaded(ac.AlsoToPreloaded({ type: "HELLO", data: 10 }));
+ assert.calledOnce(msg1.data.actor.sendAsyncMessage);
+ assert.calledOnce(msg2.data.actor.sendAsyncMessage);
+ });
+ it("should not send the message to the preloaded browser if there's no data and a preloaded browser does not exists", () => {
+ let msg = getTabDetails("foo:11");
+ mm.loadedTabs.set(msg.data.browser, msg.data);
+ const action = ac.AlsoToPreloaded({ type: "HELLO" });
+ mm.sendToPreloaded(action);
+ assert.notCalled(msg.data.actor.sendAsyncMessage);
+ });
+ });
+ });
+ describe("Handling actions", () => {
+ describe("#onActionFromContent", () => {
+ beforeEach(() => mm.onActionFromContent({ type: "FOO" }, "foo:12"));
+ it("should dispatch a AlsoToMain action", () => {
+ assert.calledOnce(dispatch);
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.type, "FOO", "action.type");
+ });
+ it("should have the right fromTarget", () => {
+ const [action] = dispatch.firstCall.args;
+ assert.equal(action.meta.fromTarget, "foo:12", "meta.fromTarget");
+ });
+ });
+ describe("#middleware", () => {
+ let store;
+ beforeEach(() => {
+ store = createStore(addNumberReducer, applyMiddleware(mm.middleware));
+ });
+ it("should just call next if no channel is found", () => {
+ store.dispatch({ type: "ADD", data: 10 });
+ assert.equal(store.getState(), 10);
+ });
+ it("should call .send but not affect the main store if an OnlyToOneContent action is dispatched", () => {
+ sinon.stub(mm, "send");
+ const action = ac.OnlyToOneContent({ type: "ADD", data: 10 }, "foo");
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.send, action);
+ assert.equal(store.getState(), 0);
+ });
+ it("should call .send and update the main store if an AlsoToOneContent action is dispatched", () => {
+ sinon.stub(mm, "send");
+ const action = ac.AlsoToOneContent({ type: "ADD", data: 10 }, "foo");
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.send, action);
+ assert.equal(store.getState(), 10);
+ });
+ it("should call .broadcast if the action is BroadcastToContent", () => {
+ sinon.stub(mm, "broadcast");
+ const action = ac.BroadcastToContent({ type: "FOO" });
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.broadcast, action);
+ });
+ it("should call .sendToPreloaded if the action is AlsoToPreloaded", () => {
+ sinon.stub(mm, "sendToPreloaded");
+ const action = ac.AlsoToPreloaded({ type: "FOO" });
+
+ store.dispatch(action);
+
+ assert.calledWith(mm.sendToPreloaded, action);
+ });
+ it("should dispatch other actions normally", () => {
+ sinon.stub(mm, "send");
+ sinon.stub(mm, "broadcast");
+ sinon.stub(mm, "sendToPreloaded");
+
+ store.dispatch({ type: "ADD", data: 1 });
+
+ assert.equal(store.getState(), 1);
+ assert.notCalled(mm.send);
+ assert.notCalled(mm.broadcast);
+ assert.notCalled(mm.sendToPreloaded);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js
new file mode 100644
index 0000000000..ebc9726def
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js
@@ -0,0 +1,113 @@
+import { DefaultPrefs, Prefs } from "lib/ActivityStreamPrefs.jsm";
+
+const TEST_PREF_CONFIG = new Map([
+ ["foo", { value: true }],
+ ["bar", { value: "BAR" }],
+ ["baz", { value: 1 }],
+ ["qux", { value: "foo", value_local_dev: "foofoo" }],
+]);
+
+describe("ActivityStreamPrefs", () => {
+ describe("Prefs", () => {
+ let p;
+ beforeEach(() => {
+ p = new Prefs();
+ });
+ it("should have get, set, and observe methods", () => {
+ assert.property(p, "get");
+ assert.property(p, "set");
+ assert.property(p, "observe");
+ });
+ describe("#observeBranch", () => {
+ let listener;
+ beforeEach(() => {
+ p._prefBranch = { addObserver: sinon.stub() };
+ listener = { onPrefChanged: sinon.stub() };
+ p.observeBranch(listener);
+ });
+ it("should add an observer", () => {
+ assert.calledOnce(p._prefBranch.addObserver);
+ assert.calledWith(p._prefBranch.addObserver, "");
+ });
+ it("should store the listener", () => {
+ assert.equal(p._branchObservers.size, 1);
+ assert.ok(p._branchObservers.has(listener));
+ });
+ it("should call listener's onPrefChanged", () => {
+ p._branchObservers.get(listener)();
+
+ assert.calledOnce(listener.onPrefChanged);
+ });
+ });
+ describe("#ignoreBranch", () => {
+ let listener;
+ beforeEach(() => {
+ p._prefBranch = {
+ addObserver: sinon.stub(),
+ removeObserver: sinon.stub(),
+ };
+ listener = {};
+ p.observeBranch(listener);
+ });
+ it("should remove the observer", () => {
+ p.ignoreBranch(listener);
+
+ assert.calledOnce(p._prefBranch.removeObserver);
+ assert.calledWith(
+ p._prefBranch.removeObserver,
+ p._prefBranch.addObserver.firstCall.args[0]
+ );
+ });
+ it("should remove the listener", () => {
+ assert.equal(p._branchObservers.size, 1);
+
+ p.ignoreBranch(listener);
+
+ assert.equal(p._branchObservers.size, 0);
+ });
+ });
+ });
+
+ describe("DefaultPrefs", () => {
+ describe("#init", () => {
+ let defaultPrefs;
+ let sandbox;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ defaultPrefs = new DefaultPrefs(TEST_PREF_CONFIG);
+ sinon.stub(defaultPrefs, "set");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should initialize a boolean pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "foo", true);
+ });
+ it("should not initialize a pref if a default exists", () => {
+ defaultPrefs.prefs.set("foo", false);
+
+ defaultPrefs.init();
+
+ assert.neverCalledWith(defaultPrefs.set, "foo", true);
+ });
+ it("should initialize a string pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "bar", "BAR");
+ });
+ it("should initialize a integer pref", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "baz", 1);
+ });
+ it("should initialize a pref with value if Firefox is not a local build", () => {
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "qux", "foo");
+ });
+ it("should initialize a pref with value_local_dev if Firefox is a local build", () => {
+ sandbox.stub(global.AppConstants, "MOZILLA_OFFICIAL").value(false);
+ defaultPrefs.init();
+ assert.calledWith(defaultPrefs.set, "qux", "foofoo");
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js
new file mode 100644
index 0000000000..f13dfd07ad
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js
@@ -0,0 +1,161 @@
+import { ActivityStreamStorage } from "lib/ActivityStreamStorage.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+let overrider = new GlobalOverrider();
+
+describe("ActivityStreamStorage", () => {
+ let sandbox;
+ let indexedDB;
+ let storage;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ indexedDB = {
+ open: sandbox.stub().resolves({}),
+ deleteDatabase: sandbox.stub().resolves(),
+ };
+ overrider.set({ IndexedDB: indexedDB });
+ storage = new ActivityStreamStorage({
+ storeNames: ["storage_test"],
+ telemetry: { handleUndesiredEvent: sandbox.stub() },
+ });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should throw if required arguments not provided", () => {
+ assert.throws(() => new ActivityStreamStorage({ telemetry: true }));
+ });
+ describe(".db", () => {
+ it("should not throw an error when accessing db", async () => {
+ assert.ok(storage.db);
+ });
+
+ it("should delete and recreate the db if opening db fails", async () => {
+ const newDb = {};
+ indexedDB.open.onFirstCall().rejects(new Error("fake error"));
+ indexedDB.open.onSecondCall().resolves(newDb);
+
+ const db = await storage.db;
+ assert.calledOnce(indexedDB.deleteDatabase);
+ assert.calledTwice(indexedDB.open);
+ assert.equal(db, newDb);
+ });
+ });
+ describe("#getDbTable", () => {
+ let testStorage;
+ let storeStub;
+ beforeEach(() => {
+ storeStub = {
+ getAll: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ put: sandbox.stub().resolves(),
+ };
+ sandbox.stub(storage, "_getStore").resolves(storeStub);
+ testStorage = storage.getDbTable("storage_test");
+ });
+ it("should reverse key value parameters for put", async () => {
+ await testStorage.set("key", "value");
+
+ assert.calledOnce(storeStub.put);
+ assert.calledWith(storeStub.put, "value", "key");
+ });
+ it("should return the correct value for get", async () => {
+ storeStub.get.withArgs("foo").resolves("foo");
+
+ const result = await testStorage.get("foo");
+
+ assert.calledOnce(storeStub.get);
+ assert.equal(result, "foo");
+ });
+ it("should return the correct value for getAll", async () => {
+ storeStub.getAll.resolves(["bar"]);
+
+ const result = await testStorage.getAll();
+
+ assert.calledOnce(storeStub.getAll);
+ assert.deepEqual(result, ["bar"]);
+ });
+ it("should query the correct object store", async () => {
+ await testStorage.get();
+
+ assert.calledOnce(storage._getStore);
+ assert.calledWithExactly(storage._getStore, "storage_test");
+ });
+ it("should throw if table is not found", () => {
+ assert.throws(() => storage.getDbTable("undefined_store"));
+ });
+ });
+ it("should get the correct objectStore when calling _getStore", async () => {
+ const objectStoreStub = sandbox.stub();
+ indexedDB.open.resolves({ objectStore: objectStoreStub });
+
+ await storage._getStore("foo");
+
+ assert.calledOnce(objectStoreStub);
+ assert.calledWithExactly(objectStoreStub, "foo", "readwrite");
+ });
+ it("should create a db with the correct store name", async () => {
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(false) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.calledOnce(dbStub.createObjectStore);
+ assert.calledWithExactly(dbStub.createObjectStore, "storage_test");
+ });
+ it("should handle an array of object store names", async () => {
+ storage = new ActivityStreamStorage({
+ storeNames: ["store1", "store2"],
+ telemetry: {},
+ });
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(false) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.calledTwice(dbStub.createObjectStore);
+ assert.calledWith(dbStub.createObjectStore, "store1");
+ assert.calledWith(dbStub.createObjectStore, "store2");
+ });
+ it("should skip creating existing stores", async () => {
+ storage = new ActivityStreamStorage({
+ storeNames: ["store1", "store2"],
+ telemetry: {},
+ });
+ const dbStub = {
+ createObjectStore: sandbox.stub(),
+ objectStoreNames: { contains: sandbox.stub().returns(true) },
+ };
+ await storage.db;
+
+ // call the cb with a stub
+ indexedDB.open.args[0][2](dbStub);
+
+ assert.notCalled(dbStub.createObjectStore);
+ });
+ describe("#_requestWrapper", () => {
+ it("should return a successful result", async () => {
+ const result = await storage._requestWrapper(() =>
+ Promise.resolve("foo")
+ );
+
+ assert.equal(result, "foo");
+ assert.notCalled(storage.telemetry.handleUndesiredEvent);
+ });
+ it("should report failures", async () => {
+ try {
+ await storage._requestWrapper(() => Promise.reject(new Error()));
+ } catch (e) {
+ assert.calledOnce(storage.telemetry.handleUndesiredEvent);
+ }
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
new file mode 100644
index 0000000000..e91b7fc549
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js
@@ -0,0 +1,3581 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "common/Actions.sys.mjs";
+import { combineReducers, createStore } from "redux";
+import { GlobalOverrider } from "test/unit/utils";
+import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { reducers } from "common/Reducers.sys.mjs";
+
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+const CONFIG_PREF_NAME = "discoverystream.config";
+const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy";
+const ENDPOINTS_PREF_NAME = "discoverystream.endpoints";
+const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions";
+const REC_IMPRESSION_TRACKING_PREF = "discoverystream.rec.impressions";
+const THIRTY_MINUTES = 30 * 60 * 1000;
+const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week
+
+const FAKE_UUID = "{foo-123-foo}";
+
+// eslint-disable-next-line max-statements
+describe("DiscoveryStreamFeed", () => {
+ let feed;
+ let feeds;
+ let recommendationProvider;
+ let sandbox;
+ let fetchStub;
+ let clock;
+ let fakeNewTabUtils;
+ let fakePktApi;
+ let globals;
+
+ const setPref = (name, value) => {
+ const action = {
+ type: at.PREF_CHANGED,
+ data: {
+ name,
+ value: typeof value === "object" ? JSON.stringify(value) : value,
+ },
+ };
+ feed.store.dispatch(action);
+ feed.onAction(action);
+ };
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+
+ // Fetch
+ fetchStub = sandbox.stub(global, "fetch");
+
+ // Time
+ clock = sinon.useFakeTimers();
+
+ globals = new GlobalOverrider();
+ globals.set({
+ gUUIDGenerator: { generateUUID: () => FAKE_UUID },
+ PersistentCache,
+ PersonalityProvider,
+ });
+
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs("browser.newtabpage.activity-stream.discoverystream.enabled")
+ .returns(true);
+
+ recommendationProvider = new RecommendationProvider();
+ recommendationProvider.store = createStore(combineReducers(reducers), {});
+ feeds = {
+ "feeds.recommendationprovider": recommendationProvider,
+ };
+
+ // Feed
+ feed = new DiscoveryStreamFeed();
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ },
+ },
+ });
+ feed.store.feeds = {
+ get: name => feeds[name],
+ };
+ global.fetch.resetHistory();
+
+ sandbox.stub(feed, "_maybeUpdateCachedData").resolves();
+
+ globals.set("setTimeout", callback => {
+ callback();
+ });
+
+ fakeNewTabUtils = {
+ blockedLinks: {
+ links: [],
+ isBlocked: () => false,
+ },
+ };
+ globals.set("NewTabUtils", fakeNewTabUtils);
+
+ fakePktApi = {
+ isUserLoggedIn: () => false,
+ getRecentSavesCache: () => null,
+ getRecentSaves: () => null,
+ };
+ globals.set("pktApi", fakePktApi);
+ });
+
+ afterEach(() => {
+ clock.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("#fetchFromEndpoint", () => {
+ beforeEach(() => {
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+ fetchStub.resolves({
+ json: () => Promise.resolve("hi"),
+ ok: true,
+ });
+ });
+ it("should get a response", async () => {
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, "hi");
+ });
+ it("should not send cookies", async () => {
+ await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.propertyVal(fetchStub.firstCall.args[1], "credentials", "omit");
+ });
+ it("should allow unexpected response", async () => {
+ fetchStub.resolves({ ok: false });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, null);
+ });
+ it("should disallow unexpected endpoints", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { [ENDPOINTS_PREF_NAME]: "https://other.site" } },
+ });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, null);
+ });
+ it("should allow multiple endpoints", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [ENDPOINTS_PREF_NAME]: `https://other.site,${DUMMY_ENDPOINT}`,
+ },
+ },
+ });
+
+ const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT);
+
+ assert.equal(response, "hi");
+ });
+ it("should replace urls with $apiKey", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("replaced");
+
+ await feed.fetchFromEndpoint(
+ "https://getpocket.cdn.mozilla.net/dummy?consumer_key=$apiKey"
+ );
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy?consumer_key=replaced",
+ { credentials: "omit" }
+ );
+ });
+ it("should replace locales with $locale", async () => {
+ feed.locale = "replaced";
+ await feed.fetchFromEndpoint(
+ "https://getpocket.cdn.mozilla.net/dummy?locale_lang=$locale"
+ );
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy?locale_lang=replaced",
+ { credentials: "omit" }
+ );
+ });
+ it("should allow POST and with other options", async () => {
+ await feed.fetchFromEndpoint("https://getpocket.cdn.mozilla.net/dummy", {
+ method: "POST",
+ body: "{}",
+ });
+
+ assert.calledWithMatch(
+ fetchStub,
+ "https://getpocket.cdn.mozilla.net/dummy",
+ {
+ credentials: "omit",
+ method: "POST",
+ body: "{}",
+ }
+ );
+ });
+ });
+
+ describe("#setupPocketState", () => {
+ it("should setup logged in state and recent saves with cache", async () => {
+ fakePktApi.isUserLoggedIn = () => true;
+ fakePktApi.getRecentSavesCache = () => [1, 2, 3];
+ sandbox.spy(feed.store, "dispatch");
+ await feed.setupPocketState({});
+ assert.calledTwice(feed.store.dispatch);
+ assert.calledWith(
+ feed.store.dispatch.firstCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
+ data: { isUserLoggedIn: true },
+ },
+ {}
+ )
+ );
+ assert.calledWith(
+ feed.store.dispatch.secondCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_RECENT_SAVES,
+ data: { recentSaves: [1, 2, 3] },
+ },
+ {}
+ )
+ );
+ });
+ it("should setup logged in state and recent saves without cache", async () => {
+ fakePktApi.isUserLoggedIn = () => true;
+ fakePktApi.getRecentSaves = ({ success }) => success([1, 2, 3]);
+ sandbox.spy(feed.store, "dispatch");
+ await feed.setupPocketState({});
+ assert.calledTwice(feed.store.dispatch);
+ assert.calledWith(
+ feed.store.dispatch.firstCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
+ data: { isUserLoggedIn: true },
+ },
+ {}
+ )
+ );
+ assert.calledWith(
+ feed.store.dispatch.secondCall,
+ ac.OnlyToOneContent(
+ {
+ type: at.DISCOVERY_STREAM_RECENT_SAVES,
+ data: { recentSaves: [1, 2, 3] },
+ },
+ {}
+ )
+ );
+ });
+ });
+
+ describe("#getOrCreateImpressionId", () => {
+ it("should create impression id in constructor", async () => {
+ assert.equal(feed._impressionId, FAKE_UUID);
+ });
+ it("should create impression id if none exists", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("");
+ sandbox.stub(global.Services.prefs, "setCharPref").returns();
+
+ const result = feed.getOrCreateImpressionId();
+
+ assert.equal(result, FAKE_UUID);
+ assert.calledOnce(global.Services.prefs.setCharPref);
+ });
+ it("should use impression id if exists", async () => {
+ sandbox.stub(global.Services.prefs, "getCharPref").returns("from get");
+
+ const result = feed.getOrCreateImpressionId();
+
+ assert.equal(result, "from get");
+ assert.calledOnce(global.Services.prefs.getCharPref);
+ });
+ });
+
+ describe("#parseGridPositions", () => {
+ it("should return an equivalent array for an array of non negative integers", async () => {
+ assert.deepEqual(feed.parseGridPositions([0, 2, 3]), [0, 2, 3]);
+ });
+ it("should return undefined for an array containing negative integers", async () => {
+ assert.equal(feed.parseGridPositions([-2, 2, 3]), undefined);
+ });
+ it("should return undefined for an undefined input", async () => {
+ assert.equal(feed.parseGridPositions(undefined), undefined);
+ });
+ });
+
+ describe("#loadLayout", () => {
+ it("should fetch data and populate the cache if it is empty", async () => {
+ const resp = { layout: ["foo", "bar"] };
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(feed.cache.set.firstCall.args[0], "layout");
+ assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout);
+ });
+ it("should fetch data and populate the cache if the cached data is older than 30 mins", async () => {
+ const resp = { layout: ["foo", "bar"] };
+ const fakeCache = {
+ layout: { layout: ["hello"], lastUpdated: Date.now() },
+ };
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ clock.tick(THIRTY_MINUTES + 1);
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(feed.cache.set.firstCall.args[0], "layout");
+ assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout);
+ });
+ it("should use the cached data and not fetch if the cached data is less than 30 mins old", async () => {
+ const fakeCache = {
+ layout: { layout: ["hello"], lastUpdated: Date.now() },
+ };
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ clock.tick(THIRTY_MINUTES - 1);
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(fetchStub);
+ assert.notCalled(feed.cache.set);
+ });
+ it("should set spocs_endpoint from layout", async () => {
+ const resp = { layout: ["foo", "bar"], spocs: { url: "foo.com" } };
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "foo.com"
+ );
+ });
+ it("should use local layout with hardcoded_layout being true", async () => {
+ feed.config.hardcoded_layout = true;
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ });
+ it("should use local basic layout with hardcoded_layout and hardcoded_basic_layout being true", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.config.hardcoded_basic_layout = true;
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use 1 row layout if specified", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.region-basic-layout": true,
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use 7 row layout if specified", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.region-basic-layout": false,
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 21);
+ });
+ it("should use new spocs endpoint if in the config", async () => {
+ feed.config.spocs_endpoint = "https://spocs.getpocket.com/spocs2";
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs2"
+ );
+ });
+ it("should use local basic layout with hardcoded_layout and FF pref hardcoded_basic_layout", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.hardcoded-basic-layout": true,
+ },
+ },
+ });
+
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.notCalled(feed.fetchLayout);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 3);
+ });
+ it("should use new spocs endpoint if in a FF pref", async () => {
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: false,
+ show_spocs: false,
+ layout_endpoint: DUMMY_ENDPOINT,
+ }),
+ [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT,
+ "discoverystream.enabled": true,
+ "discoverystream.spocs-endpoint":
+ "https://spocs.getpocket.com/spocs2",
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs2"
+ );
+ });
+ it("should fetch local layout for invalid layout endpoint or when fetch layout fails", async () => {
+ feed.config.hardcoded_layout = false;
+ fetchStub.resolves({ ok: false });
+
+ await feed.loadLayout(feed.store.dispatch, true);
+
+ assert.calledOnce(fetchStub);
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs"
+ );
+ });
+ it("should return enough stories to fill a four card layout", async () => {
+ feed.config.hardcoded_layout = true;
+
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: { fourCardLayout: true },
+ },
+ },
+ });
+
+ sandbox.stub(feed, "fetchLayout").returns(Promise.resolve(""));
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(layout[0].components[2].properties.items, 24);
+ });
+ it("should create a layout with spoc and widget positions", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocPositions: "1, 2",
+ widgetPositions: "3, 4",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[2].spocs.positions, [
+ { index: 1 },
+ { index: 2 },
+ ]);
+ assert.deepEqual(layout[0].components[2].widgets.positions, [
+ { index: 3 },
+ { index: 4 },
+ ]);
+ });
+ it("should create a layout with spoc position data", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocAdTypes: "1230",
+ spocZoneIds: "4560, 7890",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[2].placement.ad_types, [1230]);
+ assert.deepEqual(
+ layout[0].components[2].placement.zone_ids,
+ [4560, 7890]
+ );
+ });
+ it("should create a layout with spoc topsite position data", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocTopsitesAdTypes: "1230",
+ spocTopsitesZoneIds: "4560, 7890",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(layout[0].components[0].placement.ad_types, [1230]);
+ assert.deepEqual(
+ layout[0].components[0].placement.zone_ids,
+ [4560, 7890]
+ );
+ });
+ it("should create a layout with proper spoc url with a site id", async () => {
+ feed.config.hardcoded_layout = true;
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocSiteId: "1234",
+ },
+ },
+ },
+ });
+
+ await feed.loadLayout(feed.store.dispatch);
+ const { spocs } = feed.store.getState().DiscoveryStream;
+ assert.deepEqual(
+ spocs.spocs_endpoint,
+ "https://spocs.getpocket.com/spocs?site=1234"
+ );
+ });
+ });
+
+ describe("#updatePlacements", () => {
+ it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ const fakeComponents = {
+ components: [
+ { placement: { name: "first" }, spocs: {} },
+ { placement: { name: "second" }, spocs: {} },
+ ],
+ };
+ const fakeLayout = [fakeComponents];
+
+ feed.updatePlacements(feed.store.dispatch, fakeLayout);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
+ data: { placements: [{ name: "first" }, { name: "second" }] },
+ meta: { isStartup: false },
+ });
+ });
+ it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS with prefs array", () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true, withPref: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ const fakeComponents = {
+ components: [
+ { placement: { name: "withPref" }, spocs: { prefs: ["withPref"] } },
+ { placement: { name: "withoutPref1" }, spocs: {} },
+ {
+ placement: { name: "withoutPref2" },
+ spocs: { prefs: ["whatever"] },
+ },
+ { placement: { name: "withoutPref3" }, spocs: { prefs: [] } },
+ ],
+ };
+ const fakeLayout = [fakeComponents];
+
+ feed.updatePlacements(feed.store.dispatch, fakeLayout);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS",
+ data: { placements: [{ name: "withPref" }, { name: "withoutPref1" }] },
+ meta: { isStartup: false },
+ });
+ });
+ it("should fire update placements from loadLayout", async () => {
+ sandbox.spy(feed, "updatePlacements");
+
+ await feed.loadLayout(feed.store.dispatch);
+
+ assert.calledOnce(feed.updatePlacements);
+ });
+ });
+
+ describe("#placementsForEach", () => {
+ it("should forEach through placements", () => {
+ feed.store.getState = () => ({
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "first" }, { name: "second" }],
+ },
+ },
+ });
+
+ let items = [];
+
+ feed.placementsForEach(item => items.push(item.name));
+
+ assert.deepEqual(items, ["first", "second"]);
+ });
+ });
+
+ describe("#loadLayoutEndPointUsingPref", () => {
+ it("should return endpoint if valid key", async () => {
+ const endpoint = feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ "test_key_val"
+ );
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=test_key_val",
+ endpoint
+ );
+ });
+
+ it("should throw error if key is empty", async () => {
+ assert.throws(() => {
+ feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ ""
+ );
+ });
+ });
+
+ it("should return url if $apiKey is missing in layout_endpoint", async () => {
+ const endpoint = feed.finalLayoutEndpoint(
+ "https://somedomain.org/stories?consumer_key=",
+ "test_key_val"
+ );
+ assert.equal("https://somedomain.org/stories?consumer_key=", endpoint);
+ });
+
+ it("should update config layout_endpoint based on api_key_pref value", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ api_key_pref: "test_api_key_pref",
+ enabled: true,
+ layout_endpoint:
+ "https://somedomain.org/stories?consumer_key=$apiKey",
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=test_api_key_val",
+ feed.config.layout_endpoint
+ );
+ });
+
+ it("should not update config layout_endpoint if api_key_pref missing", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ layout_endpoint:
+ "https://somedomain.org/stories?consumer_key=1234",
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.notCalled(global.Services.prefs.getCharPref);
+ assert.equal(
+ "https://somedomain.org/stories?consumer_key=1234",
+ feed.config.layout_endpoint
+ );
+ });
+
+ it("should not set config layout_endpoint if layout_endpoint missing in prefs", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [CONFIG_PREF_NAME]: JSON.stringify({
+ enabled: true,
+ }),
+ },
+ },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getCharPref")
+ .returns("test_api_key_val");
+ assert.notCalled(global.Services.prefs.getCharPref);
+ assert.isUndefined(feed.config.layout_endpoint);
+ });
+ });
+
+ describe("#loadComponentFeeds", () => {
+ let fakeCache;
+ let fakeDiscoveryStream;
+ beforeEach(() => {
+ fakeDiscoveryStream = {
+ Prefs: {},
+ DiscoveryStream: {
+ layout: [
+ { components: [{ feed: { url: "foo.com" } }] },
+ { components: [{}] },
+ {},
+ ],
+ },
+ };
+ fakeCache = {};
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should not dispatch updates when layout is not defined", async () => {
+ fakeDiscoveryStream = {
+ DiscoveryStream: {},
+ };
+ feed.store.getState.returns(fakeDiscoveryStream);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.notCalled(feed.store.dispatch);
+ });
+
+ it("should populate feeds cache", async () => {
+ fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "feeds", {
+ "foo.com": { data: "data", lastUpdated: 0 },
+ });
+ });
+
+ it("should send feed update events with new feed data", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.spy(feed.store, "dispatch");
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledWith(feed.store.dispatch.firstCall, {
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: { feed: { data: { status: "failed" } }, url: "foo.com" },
+ meta: { isStartup: false },
+ });
+ assert.calledWith(feed.store.dispatch.secondCall, {
+ type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
+ meta: { isStartup: false },
+ });
+ });
+
+ it("should return number of promises equal to unique urls", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(global.Promise, "all").resolves();
+ fakeDiscoveryStream = {
+ DiscoveryStream: {
+ layout: [
+ {
+ components: [
+ { feed: { url: "foo.com" } },
+ { feed: { url: "bar.com" } },
+ ],
+ },
+ { components: [{ feed: { url: "foo.com" } }] },
+ {},
+ { components: [{ feed: { url: "baz.com" } }] },
+ ],
+ },
+ };
+ feed.store.getState.returns(fakeDiscoveryStream);
+
+ await feed.loadComponentFeeds(feed.store.dispatch);
+
+ assert.calledOnce(global.Promise.all);
+ const { args } = global.Promise.all.firstCall;
+ assert.equal(args[0].length, 3);
+ });
+ });
+
+ describe("#getComponentFeed", () => {
+ it("should fetch fresh feed data if cache is empty", async () => {
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data.recommendations, "data");
+ });
+ it("should fetch fresh feed data if cache is old", async () => {
+ const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ clock.tick(THIRTY_MINUTES + 1);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data.recommendations, "data");
+ });
+ it("should return feed data from cache if it is fresh", async () => {
+ const fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").resolves(fakeCache);
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("old data");
+ clock.tick(THIRTY_MINUTES - 1);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.equal(feedResp.data, "data");
+ });
+ it("should return null if no response was received", async () => {
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+
+ const feedResp = await feed.getComponentFeed("foo.com");
+
+ assert.deepEqual(feedResp, { data: { status: "failed" } });
+ });
+ });
+
+ describe("#personalizationOverride", () => {
+ it("should dispatch setPref", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.personalization.enabled": true,
+ },
+ },
+ });
+
+ feed.personalizationOverride(true);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: "discoverystream.personalization.override",
+ value: true,
+ },
+ type: at.SET_PREF,
+ });
+ });
+ it("should dispatch CLEAR_PREF", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.personalization.enabled": true,
+ "discoverystream.personalization.override": true,
+ },
+ },
+ });
+
+ feed.personalizationOverride(false);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: "discoverystream.personalization.override",
+ },
+ type: at.CLEAR_PREF,
+ });
+ });
+ });
+
+ describe("#loadSpocs", () => {
+ beforeEach(() => {
+ feed._prefCache = {
+ config: {
+ api_key_pref: "",
+ },
+ };
+
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ });
+ it("should not fetch or update cache if no spocs endpoint is defined", async () => {
+ feed.store.dispatch(
+ ac.BroadcastToContent({
+ type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
+ data: "",
+ })
+ );
+
+ sandbox.spy(feed.cache, "set");
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.notCalled(global.fetch);
+ assert.notCalled(feed.cache.set);
+ });
+ it("should fetch fresh spocs data if cache is empty", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "spocs", {
+ spocs: { placement: "data" },
+ lastUpdated: 0,
+ });
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "data"
+ );
+ });
+ it("should fetch fresh data if cache is old", async () => {
+ const cachedSpoc = {
+ spocs: { placement: "old" },
+ lastUpdated: Date.now(),
+ };
+ const cachedData = { spocs: cachedSpoc };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "new"
+ );
+ });
+ it("should return spoc data from cache if it is fresh", async () => {
+ const cachedSpoc = {
+ spocs: { placement: "old" },
+ lastUpdated: Date.now(),
+ };
+ const cachedData = { spocs: cachedSpoc };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ clock.tick(THIRTY_MINUTES - 1);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.equal(
+ feed.store.getState().DiscoveryStream.spocs.data.placement,
+ "old"
+ );
+ });
+ it("should properly transform spocs using placements", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ spocs: { items: [{ id: "data" }] } });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledWith(feed.cache.set, "spocs", {
+ spocs: {
+ spocs: {
+ context: "",
+ title: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ },
+ lastUpdated: 0,
+ });
+
+ assert.deepEqual(
+ feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
+ { id: "data", score: 1 }
+ );
+ });
+ it("should normalizeSpocsItems for older spoc data", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ spocs: [{ id: "data" }] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(
+ feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0],
+ { id: "data", score: 1 }
+ );
+ });
+ it("should call personalizationVersionOverride with feature_flags", async () => {
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "personalizationOverride").returns();
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ settings: { feature_flags: {} }, spocs: [{ id: "data" }] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.calledOnce(feed.personalizationOverride);
+ });
+ it("should return expected data if normalizeSpocsItems returns no spoc data", async () => {
+ // We don't need this for just this test, we are setting placements manually.
+ feed.getPlacements.restore();
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox
+ .stub(feed, "fetchFromEndpoint")
+ .resolves({ placement1: [{ id: "data" }], placement2: [] });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ const fakeComponents = {
+ components: [
+ { placement: { name: "placement1" }, spocs: {} },
+ { placement: { name: "placement2" }, spocs: {} },
+ ],
+ };
+ feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+ placement1: {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ placement2: {
+ title: "",
+ context: "",
+ items: [],
+ },
+ });
+ });
+ it("should use title and context on spoc data", async () => {
+ // We don't need this for just this test, we are setting placements manually.
+ feed.getPlacements.restore();
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve());
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ placement1: {
+ title: "title",
+ context: "context",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data" }],
+ },
+ });
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ const fakeComponents = {
+ components: [{ placement: { name: "placement1" }, spocs: {} }],
+ };
+ feed.updatePlacements(feed.store.dispatch, [fakeComponents]);
+
+ await feed.loadSpocs(feed.store.dispatch);
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, {
+ placement1: {
+ title: "title",
+ context: "context",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "data", score: 1 }],
+ },
+ });
+ });
+ });
+
+ describe("#normalizeSpocsItems", () => {
+ it("should return correct data if new data passed in", async () => {
+ const spocs = {
+ title: "title",
+ context: "context",
+ sponsor: "sponsor",
+ sponsored_by_override: "override",
+ items: [{ id: "id" }],
+ };
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, spocs);
+ });
+ it("should return normalized data if new data passed in without title or context", async () => {
+ const spocs = {
+ items: [{ id: "id" }],
+ };
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "id" }],
+ });
+ });
+ it("should return normalized data if old data passed in", async () => {
+ const spocs = [{ id: "id" }];
+ const result = feed.normalizeSpocsItems(spocs);
+ assert.deepEqual(result, {
+ title: "",
+ context: "",
+ sponsor: "",
+ sponsored_by_override: undefined,
+ items: [{ id: "id" }],
+ });
+ });
+ });
+
+ describe("#showSpocs", () => {
+ it("should return true from showSpocs if showSponsoredStories is false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => false,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => true,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return true from showSpocs if showSponsoredTopsites is false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => false,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return true from showSpocs if both are true", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => true,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => true,
+ });
+ assert.isTrue(feed.showSpocs);
+ });
+ it("should return false from showSpocs if both are false", async () => {
+ Object.defineProperty(feed, "showSponsoredStories", {
+ get: () => false,
+ });
+ Object.defineProperty(feed, "showSponsoredTopsites", {
+ get: () => false,
+ });
+ assert.isFalse(feed.showSpocs);
+ });
+ });
+
+ describe("#showSponsoredStories", () => {
+ it("should return false from showSponsoredStories if user pref showSponsored is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: false } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+
+ assert.isFalse(feed.showSponsoredStories);
+ });
+ it("should return false from showSponsoredStories if DiscoveryStream pref show_spocs is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: false }),
+ });
+
+ assert.isFalse(feed.showSponsoredStories);
+ });
+ it("should return true from showSponsoredStories if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsored: true } },
+ });
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+
+ assert.isTrue(feed.showSponsoredStories);
+ });
+ });
+
+ describe("#showSponsoredTopsites", () => {
+ it("should return false from showSponsoredTopsites if user pref showSponsoredTopSites is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsoredTopSites: false } },
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ },
+ });
+ assert.isFalse(feed.showSponsoredTopsites);
+ });
+ it("should return true from showSponsoredTopsites if user pref showSponsoredTopSites is true", async () => {
+ feed.store.getState = () => ({
+ Prefs: { values: { showSponsoredTopSites: true } },
+ DiscoveryStream: {
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ },
+ });
+ assert.isTrue(feed.showSponsoredTopsites);
+ });
+ });
+
+ describe("#showStories", () => {
+ it("should return false from showStories if user pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": false,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ assert.isFalse(feed.showStories);
+ });
+ it("should return false from showStories if system pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": false,
+ },
+ },
+ });
+ assert.isFalse(feed.showStories);
+ });
+ it("should return true from showStories if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ assert.isTrue(feed.showStories);
+ });
+ });
+
+ describe("#showTopsites", () => {
+ it("should return false from showTopsites if user pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": false,
+ "feeds.system.topsites": true,
+ },
+ },
+ });
+ assert.isFalse(feed.showTopsites);
+ });
+ it("should return false from showTopsites if system pref is false", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": true,
+ "feeds.system.topsites": false,
+ },
+ },
+ });
+ assert.isFalse(feed.showTopsites);
+ });
+ it("should return true from showTopsites if both prefs are true", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "feeds.topsites": true,
+ "feeds.system.topsites": true,
+ },
+ },
+ });
+ assert.isTrue(feed.showTopsites);
+ });
+ });
+
+ describe("#clearSpocs", () => {
+ let defaultState;
+ let DiscoveryStream;
+ let Prefs;
+ beforeEach(() => {
+ Object.defineProperty(feed, "config", {
+ get: () => ({ show_spocs: true }),
+ });
+ DiscoveryStream = {
+ layout: [],
+ spocs: {
+ placements: [{ name: "sponsored-topsites" }],
+ },
+ };
+ Prefs = {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ "feeds.topsites": true,
+ "feeds.system.topsites": true,
+ showSponsoredTopSites: true,
+ showSponsored: true,
+ },
+ };
+ defaultState = {
+ DiscoveryStream,
+ Prefs,
+ };
+ feed.store.getState = () => defaultState;
+ });
+ it("should not fail with no endpoint", async () => {
+ sandbox.stub(feed.store, "getState").returns({
+ Prefs: {
+ values: { "discoverystream.endpointSpocsClear": null },
+ },
+ });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+
+ await feed.clearSpocs();
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should call DELETE with endpoint", async () => {
+ sandbox.stub(feed.store, "getState").returns({
+ Prefs: {
+ values: {
+ "discoverystream.endpointSpocsClear": "https://spocs/user",
+ },
+ },
+ });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves(null);
+ feed._impressionId = "1234";
+
+ await feed.clearSpocs();
+
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[0],
+ "https://spocs/user"
+ );
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "DELETE");
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[1].body,
+ '{"pocket_id":"1234"}'
+ );
+ });
+ it("should properly call clearSpocs when sponsored content is changed", async () => {
+ sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
+ //sandbox.stub(feed, "updatePlacements").returns();
+ sandbox.stub(feed, "loadSpocs").returns();
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.notCalled(feed.clearSpocs);
+
+ Prefs.values.showSponsoredTopSites = false;
+ Prefs.values.showSponsored = false;
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.clearSpocs);
+ });
+ it("should call clearSpocs when top stories and top sites is turned off", async () => {
+ sandbox.stub(feed, "clearSpocs").returns(Promise.resolve());
+ Prefs.values["feeds.section.topstories"] = false;
+ Prefs.values["feeds.topsites"] = false;
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.section.topstories" },
+ });
+
+ assert.calledOnce(feed.clearSpocs);
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.topsites" },
+ });
+
+ assert.calledTwice(feed.clearSpocs);
+ });
+ });
+
+ describe("#rotate", () => {
+ it("should move seen first story to the back of the response", () => {
+ const recsExpireTime = 5600;
+ const feedResponse = {
+ recommendations: [
+ {
+ id: "first",
+ },
+ {
+ id: "second",
+ },
+ {
+ id: "third",
+ },
+ {
+ id: "fourth",
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ };
+ const fakeImpressions = {
+ first: Date.now() - recsExpireTime * 1000,
+ third: Date.now(),
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+
+ const result = feed.rotate(
+ feedResponse.recommendations,
+ feedResponse.settings.recsExpireTime
+ );
+
+ assert.equal(result[3].id, "first");
+ });
+ });
+
+ describe("#reset", () => {
+ it("should fire all reset based functions", async () => {
+ sandbox.stub(global.Services.obs, "removeObserver").returns();
+
+ sandbox.stub(feed, "resetDataPrefs").returns();
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "resetState").returns();
+
+ feed.loaded = true;
+
+ await feed.reset();
+
+ assert.calledOnce(feed.resetDataPrefs);
+ assert.calledOnce(feed.resetCache);
+ assert.calledOnce(feed.resetState);
+ assert.calledOnce(global.Services.obs.removeObserver);
+ });
+ });
+
+ describe("#resetCache", () => {
+ it("should set .layout, .feeds .spocs and .personalization to {}", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+
+ await feed.resetCache();
+
+ assert.callCount(feed.cache.set, 4);
+ const firstCall = feed.cache.set.getCall(0);
+ const secondCall = feed.cache.set.getCall(1);
+ const thirdCall = feed.cache.set.getCall(2);
+ const fourthCall = feed.cache.set.getCall(3);
+ assert.deepEqual(firstCall.args, ["layout", {}]);
+ assert.deepEqual(secondCall.args, ["feeds", {}]);
+ assert.deepEqual(thirdCall.args, ["spocs", {}]);
+ assert.deepEqual(fourthCall.args, ["personalization", {}]);
+ });
+ });
+
+ describe("#scoreItems", () => {
+ it("should return initial data if spocs are empty", async () => {
+ const { data: result } = await feed.scoreItems([]);
+
+ assert.equal(result.length, 0);
+ });
+
+ it("should sort based on item_score", async () => {
+ const { data: result } = await feed.scoreItems([
+ { id: 2, flight_id: 2, item_score: 0.8 },
+ { id: 4, flight_id: 4, item_score: 0.5 },
+ { id: 3, flight_id: 3, item_score: 0.7 },
+ { id: 1, flight_id: 1, item_score: 0.9 },
+ ]);
+
+ assert.deepEqual(result, [
+ { id: 1, flight_id: 1, item_score: 0.9, score: 0.9 },
+ { id: 2, flight_id: 2, item_score: 0.8, score: 0.8 },
+ { id: 3, flight_id: 3, item_score: 0.7, score: 0.7 },
+ { id: 4, flight_id: 4, item_score: 0.5, score: 0.5 },
+ ]);
+ });
+
+ it("should sort based on priority", async () => {
+ const { data: result } = await feed.scoreItems([
+ { id: 6, flight_id: 6, priority: 2, item_score: 0.7 },
+ { id: 2, flight_id: 3, priority: 1, item_score: 0.2 },
+ { id: 4, flight_id: 4, item_score: 0.6 },
+ { id: 5, flight_id: 5, priority: 2, item_score: 0.8 },
+ { id: 3, flight_id: 3, item_score: 0.8 },
+ { id: 1, flight_id: 1, priority: 1, item_score: 0.3 },
+ ]);
+
+ assert.deepEqual(result, [
+ {
+ id: 1,
+ flight_id: 1,
+ priority: 1,
+ score: 0.3,
+ item_score: 0.3,
+ },
+ {
+ id: 2,
+ flight_id: 3,
+ priority: 1,
+ score: 0.2,
+ item_score: 0.2,
+ },
+ {
+ id: 5,
+ flight_id: 5,
+ priority: 2,
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ id: 6,
+ flight_id: 6,
+ priority: 2,
+ score: 0.7,
+ item_score: 0.7,
+ },
+ { id: 3, flight_id: 3, item_score: 0.8, score: 0.8 },
+ { id: 4, flight_id: 4, item_score: 0.6, score: 0.6 },
+ ]);
+ });
+
+ it("should add a score prop to spocs", async () => {
+ const { data: result } = await feed.scoreItems([
+ { flight_id: 1, item_score: 0.9 },
+ ]);
+
+ assert.equal(result[0].score, 0.9);
+ });
+ });
+
+ describe("#filterBlocked", () => {
+ it("should return initial data if spocs are empty", () => {
+ const { data: result } = feed.filterBlocked([]);
+
+ assert.equal(result.length, 0);
+ });
+ it("should return initial data if links are not blocked", () => {
+ const { data: result } = feed.filterBlocked([
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ]);
+ assert.equal(result.length, 2);
+ });
+ it("should return initial recommendations data if links are not blocked", () => {
+ const { data: result } = feed.filterBlocked([
+ { url: "https://foo.com" },
+ { url: "test.com" },
+ ]);
+ assert.equal(result.length, 2);
+ });
+ it("filterRecommendations based on blockedlist by passing feed data", () => {
+ fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ fakeNewTabUtils.blockedLinks.links[0].url === site.url;
+
+ const result = feed.filterRecommendations({
+ lastUpdated: 4,
+ data: {
+ recommendations: [{ url: "https://foo.com" }, { url: "test.com" }],
+ },
+ });
+
+ assert.equal(result.lastUpdated, 4);
+ assert.lengthOf(result.data.recommendations, 1);
+ assert.equal(result.data.recommendations[0].url, "test.com");
+ assert.notInclude(
+ result.data.recommendations,
+ fakeNewTabUtils.blockedLinks.links[0]
+ );
+ });
+ });
+
+ describe("#frequencyCapSpocs", () => {
+ it("should return filtered out spocs based on frequency caps", () => {
+ const fakeSpocs = [
+ {
+ id: 1,
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ];
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+
+ const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0].flight_id, "not-seen");
+ assert.deepEqual(filtered, [fakeSpocs[0]]);
+ });
+ it("should return simple structure and do nothing with no spocs", () => {
+ const { data: result, filtered } = feed.frequencyCapSpocs([]);
+
+ assert.equal(result.length, 0);
+ assert.equal(filtered.length, 0);
+ });
+ });
+
+ describe("#migrateFlightId", () => {
+ it("should migrate campaign to flight if no flight exists", () => {
+ const fakeSpocs = [
+ {
+ id: 1,
+ campaign_id: "campaign",
+ caps: {
+ lifetime: 3,
+ campaign: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ];
+ const { data: result } = feed.migrateFlightId(fakeSpocs);
+
+ assert.deepEqual(result[0], {
+ id: 1,
+ flight_id: "campaign",
+ campaign_id: "campaign",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ campaign: {
+ count: 1,
+ period: 1,
+ },
+ },
+ });
+ });
+ it("should not migrate campaign to flight if caps or id don't exist", () => {
+ const fakeSpocs = [{ id: 1 }];
+ const { data: result } = feed.migrateFlightId(fakeSpocs);
+
+ assert.deepEqual(result[0], { id: 1 });
+ });
+ it("should return simple structure and do nothing with no spocs", () => {
+ const { data: result } = feed.migrateFlightId([]);
+
+ assert.equal(result.length, 0);
+ });
+ });
+
+ describe("#isBelowFrequencyCap", () => {
+ it("should return true if there are no flight impressions", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isTrue(result);
+ });
+ it("should return true if there are no flight caps", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isTrue(result);
+ });
+
+ it("should return false if lifetime cap is hit", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 1,
+ flight: {
+ count: 3,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isFalse(result);
+ });
+
+ it("should return false if time based cap is hit", () => {
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const fakeSpoc = {
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ };
+
+ const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc);
+
+ assert.isFalse(result);
+ });
+ });
+
+ describe("#retryFeed", () => {
+ it("should retry a feed fetch", async () => {
+ sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({}));
+ sandbox.stub(feed, "filterRecommendations").returns({});
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.retryFeed({ url: "https://feed.com" });
+
+ assert.calledOnce(feed.getComponentFeed);
+ assert.calledOnce(feed.filterRecommendations);
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ "DISCOVERY_STREAM_FEED_UPDATE"
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ feed: {},
+ url: "https://feed.com",
+ });
+ });
+ });
+
+ describe("#recordFlightImpression", () => {
+ it("should return false if time based cap is hit", () => {
+ sandbox.stub(feed, "readDataPref").returns({});
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.recordFlightImpression("seen");
+
+ assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
+ seen: [0],
+ });
+ });
+ });
+
+ describe("#recordBlockFlightId", () => {
+ it("should call writeDataPref with new flight id added", () => {
+ sandbox.stub(feed, "readDataPref").returns({ 1234: 1 });
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.recordBlockFlightId("5678");
+
+ assert.calledOnce(feed.readDataPref);
+ assert.calledWith(feed.writeDataPref, "discoverystream.flight.blocks", {
+ 1234: 1,
+ 5678: 1,
+ });
+ });
+ });
+
+ describe("#cleanUpFlightImpressionPref", () => {
+ it("should remove flight-3 because it is no longer being used", async () => {
+ const fakeSpocs = {
+ spocs: {
+ items: [
+ {
+ flight_id: "flight-1",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ flight_id: "flight-2",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ const fakeImpressions = {
+ "flight-2": [Date.now() - 1],
+ "flight-3": [Date.now() - 1],
+ };
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.cleanUpFlightImpressionPref(fakeSpocs);
+
+ assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, {
+ "flight-2": [-1],
+ });
+ });
+ });
+
+ describe("#recordTopRecImpressions", () => {
+ it("should add a rec id to the rec impression pref", () => {
+ sandbox.stub(feed, "readDataPref").returns({});
+ sandbox.stub(feed, "writeDataPref");
+
+ feed.recordTopRecImpressions("rec");
+
+ assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, {
+ rec: 0,
+ });
+ });
+ it("should not add an impression if it already exists", () => {
+ sandbox.stub(feed, "readDataPref").returns({ rec: 4 });
+ sandbox.stub(feed, "writeDataPref");
+
+ feed.recordTopRecImpressions("rec");
+
+ assert.notCalled(feed.writeDataPref);
+ });
+ });
+
+ describe("#cleanUpTopRecImpressionPref", () => {
+ it("should remove recs no longer being used", () => {
+ const newFeeds = {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "rec1",
+ },
+ {
+ id: "rec2",
+ },
+ ],
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "rec3",
+ },
+ {
+ id: "rec4",
+ },
+ ],
+ },
+ },
+ };
+ const fakeImpressions = {
+ rec2: Date.now() - 1,
+ rec3: Date.now() - 1,
+ rec5: Date.now() - 1,
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.stub(feed, "writeDataPref").returns();
+
+ feed.cleanUpTopRecImpressionPref(newFeeds);
+
+ assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, {
+ rec2: -1,
+ rec3: -1,
+ });
+ });
+ });
+
+ describe("#writeDataPref", () => {
+ it("should call Services.prefs.setStringPref", () => {
+ sandbox.spy(feed.store, "dispatch");
+ const fakeImpressions = {
+ foo: [Date.now() - 1],
+ bar: [Date.now() - 1],
+ };
+
+ feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: SPOC_IMPRESSION_TRACKING_PREF,
+ value: JSON.stringify(fakeImpressions),
+ },
+ type: at.SET_PREF,
+ });
+ });
+ });
+
+ describe("#addEndpointQuery", () => {
+ const url = "https://spocs.getpocket.com/spocs";
+
+ it("should return same url with no query", () => {
+ const result = feed.addEndpointQuery(url, "");
+ assert.equal(result, url);
+ });
+
+ it("should add multiple query params to standard url", () => {
+ const params = "?first=first&second=second";
+ const result = feed.addEndpointQuery(url, params);
+ assert.equal(result, url + params);
+ });
+
+ it("should add multiple query params to url with a query already", () => {
+ const params = "first=first&second=second";
+ const initialParams = "?zero=zero";
+ const result = feed.addEndpointQuery(
+ `${url}${initialParams}`,
+ `?${params}`
+ );
+ assert.equal(result, `${url}${initialParams}&${params}`);
+ });
+ });
+
+ describe("#readDataPref", () => {
+ it("should return what's in Services.prefs.getStringPref", () => {
+ const fakeImpressions = {
+ foo: [Date.now() - 1],
+ bar: [Date.now() - 1],
+ };
+ setPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions);
+
+ const result = feed.readDataPref(SPOC_IMPRESSION_TRACKING_PREF);
+
+ assert.deepEqual(result, fakeImpressions);
+ });
+ });
+
+ describe("#setupPrefs", () => {
+ it("should call setupPrefs", async () => {
+ sandbox.spy(feed, "setupPrefs");
+ feed.onAction({
+ type: at.INIT,
+ });
+ assert.calledOnce(feed.setupPrefs);
+ });
+ it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ globals.set("ExperimentAPI", {
+ getExperimentMetaData: () => ({
+ slug: "experimentId",
+ branch: {
+ slug: "branchId",
+ },
+ }),
+ getRolloutMetaData: () => ({}),
+ });
+ global.Services.prefs.getBoolPref
+ .withArgs("extensions.pocket.enabled")
+ .returns(true);
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ region: "CA",
+ pocketConfig: {
+ recentSavesEnabled: true,
+ hideDescriptions: false,
+ hideDescriptionsRegions: "US,CA,GB",
+ compactImages: true,
+ imageGradient: true,
+ newSponsoredLabel: true,
+ titleLines: "1",
+ descLines: "1",
+ readTime: true,
+ saveToPocketCard: false,
+ saveToPocketCardRegions: "US,CA,GB",
+ },
+ },
+ },
+ });
+ feed.setupPrefs();
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ utmSource: "pocket-newtab",
+ utmCampaign: "experimentId",
+ utmContent: "branchId",
+ });
+ assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
+ recentSavesEnabled: true,
+ pocketButtonEnabled: true,
+ saveToPocketCard: true,
+ hideDescriptions: true,
+ compactImages: true,
+ imageGradient: true,
+ newSponsoredLabel: true,
+ titleLines: "1",
+ descLines: "1",
+ readTime: true,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_IMPRESSION_STATS", () => {
+ it("should call recordTopRecImpressions from DISCOVERY_STREAM_IMPRESSION_STATS", async () => {
+ sandbox.stub(feed, "recordTopRecImpressions").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_IMPRESSION_STATS,
+ data: { tiles: [{ id: "seen" }] },
+ });
+
+ assert.calledWith(feed.recordTopRecImpressions, "seen");
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => {
+ beforeEach(() => {
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 1,
+ flight_id: "seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ },
+ },
+ });
+ });
+
+ it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => {
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {
+ seen: [Date.now() - 1],
+ };
+ const result = {
+ spocs: {
+ items: [
+ {
+ id: 2,
+ flight_id: "not-seen",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flightId: "seen" },
+ });
+
+ assert.deepEqual(
+ feed.store.dispatch.secondCall.args[0].data.spocs,
+ result
+ );
+ });
+ it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => {
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {};
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flight_id: "seen" },
+ });
+
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should attempt feq cap on valid spocs with placements on impression", async () => {
+ sandbox.restore();
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ const fakeImpressions = {};
+ sandbox.stub(feed, "recordFlightImpression").returns();
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ sandbox.spy(feed.store, "dispatch");
+ sandbox.spy(feed, "frequencyCapSpocs");
+
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 2,
+ flight_id: "seen-2",
+ caps: {
+ lifetime: 3,
+ flight: {
+ count: 1,
+ period: 1,
+ },
+ },
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ placements: [{ name: "spocs" }, { name: "notSpocs" }],
+ },
+ },
+ });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flight_id: "doesn't matter" },
+ });
+
+ assert.calledOnce(feed.frequencyCapSpocs);
+ assert.calledWith(feed.frequencyCapSpocs, data.spocs.items);
+ });
+ });
+
+ describe("#onAction: PLACES_LINK_BLOCKED", () => {
+ beforeEach(() => {
+ const data = {
+ spocs: {
+ items: [
+ {
+ id: 1,
+ flight_id: "foo",
+ url: "foo.com",
+ },
+ {
+ id: 2,
+ flight_id: "bar",
+ url: "bar.com",
+ },
+ ],
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns({
+ DiscoveryStream: {
+ spocs: {
+ data,
+ placements: [{ name: "spocs" }],
+ },
+ },
+ });
+ });
+
+ it("should call dispatch if found a blocked spoc", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ });
+
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.url,
+ "foo.com"
+ );
+ });
+ it("should dispatch once if the blocked is not a SPOC", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "not_a_spoc.com" },
+ });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.url,
+ "not_a_spoc.com"
+ );
+ });
+ it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => {
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "foo.com" },
+ });
+
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ "DISCOVERY_STREAM_SPOC_BLOCKED"
+ );
+ });
+ });
+
+ describe("#onAction: BLOCK_URL", () => {
+ it("should call recordBlockFlightId whith BLOCK_URL", async () => {
+ sandbox.stub(feed, "recordBlockFlightId").returns();
+
+ await feed.onAction({
+ type: at.BLOCK_URL,
+ data: [
+ {
+ flight_id: "1234",
+ },
+ ],
+ });
+
+ assert.calledWith(feed.recordBlockFlightId, "1234");
+ });
+ });
+
+ describe("#onAction: INIT", () => {
+ it("should be .loaded=false before initialization", () => {
+ assert.isFalse(feed.loaded);
+ });
+ it("should load data and set .loaded=true if config.enabled is true", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+
+ await feed.onAction({ type: at.INIT });
+
+ assert.calledOnce(feed.loadLayout);
+ assert.isTrue(feed.loaded);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", async () => {
+ it("should add the new value to the pref without changing the existing values", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
+ data: { name: "layout_endpoint", value: "foo.com" },
+ });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: CONFIG_PREF_NAME,
+ value: JSON.stringify({
+ enabled: true,
+ other: "value",
+ layout_endpoint: "foo.com",
+ }),
+ },
+ type: at.SET_PREF,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_POCKET_STATE_INIT", async () => {
+ it("should call setupPocketState", async () => {
+ sandbox.spy(feed, "setupPocketState");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(feed.setupPocketState);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => {
+ it("should call configReset", async () => {
+ sandbox.spy(feed, "configReset");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET,
+ });
+ assert.calledOnce(feed.configReset);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", async () => {
+ it("Should dispatch CLEAR_PREF with pref name", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
+ });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ data: {
+ name: CONFIG_PREF_NAME,
+ },
+ type: at.CLEAR_PREF,
+ });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => {
+ it("should call retryFeed", async () => {
+ sandbox.spy(feed, "retryFeed");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_RETRY_FEED,
+ data: { feed: { url: "https://feed.com" } },
+ });
+ assert.calledOnce(feed.retryFeed);
+ assert.calledWith(feed.retryFeed, { url: "https://feed.com" });
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ it("should call this.loadLayout if config.enabled changes to true ", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // First initialize
+ await feed.onAction({ type: at.INIT });
+ assert.isFalse(feed.loaded);
+
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledOnce(feed.loadLayout);
+ assert.calledOnce(feed.resetCache);
+ assert.isTrue(feed.loaded);
+ });
+ it("should clear the cache if a config change happens and config.enabled is true", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledOnce(feed.resetCache);
+ });
+ it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => {
+ sandbox.stub(feed, "resetDataPrefs");
+ sandbox.stub(feed, "resetCache").resolves();
+ sandbox.stub(feed, "enable").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+ sandbox.spy(feed.store, "dispatch");
+
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.calledWithMatch(feed.store.dispatch, {
+ type: at.DISCOVERY_STREAM_LAYOUT_RESET,
+ });
+ });
+ it("should not call this.loadLayout if config.enabled changes to false", async () => {
+ sandbox.stub(feed.cache, "set").returns(Promise.resolve());
+ // force clear cached pref value
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+ assert.isTrue(feed.loaded);
+
+ feed._prefCache = {};
+ setPref(CONFIG_PREF_NAME, { enabled: false });
+ sandbox.stub(feed, "resetCache").returns(Promise.resolve());
+ sandbox.stub(feed, "loadLayout").returns(Promise.resolve());
+ await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE });
+
+ assert.notCalled(feed.loadLayout);
+ assert.calledOnce(feed.resetCache);
+ assert.isFalse(feed.loaded);
+ });
+ });
+
+ describe("#onAction: UNINIT", () => {
+ it("should reset pref cache", async () => {
+ feed._prefCache = { cached: "value" };
+
+ await feed.onAction({ type: at.UNINIT });
+
+ assert.deepEqual(feed._prefCache, {});
+ });
+ });
+
+ describe("#onAction: PREF_CHANGED", () => {
+ it("should update state.DiscoveryStream.config when the pref changes", async () => {
+ setPref(CONFIG_PREF_NAME, {
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: "foo",
+ });
+
+ assert.deepEqual(feed.store.getState().DiscoveryStream.config, {
+ enabled: true,
+ show_spocs: false,
+ layout_endpoint: "foo",
+ });
+ });
+ it("should fire loadSpocs is showSponsored pref changes", async () => {
+ sandbox.stub(feed, "loadSpocs").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.loadSpocs);
+ });
+ it("should fire onPrefChange when pocketConfig pref changes", async () => {
+ sandbox.stub(feed, "onPrefChange").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "pocketConfig", value: false },
+ });
+
+ assert.calledOnce(feed.onPrefChange);
+ });
+ it("should fire onCollectionsChanged when collections pref changes", async () => {
+ sandbox.stub(feed, "onCollectionsChanged").returns(Promise.resolve());
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.sponsored-collections.enabled" },
+ });
+
+ assert.calledOnce(feed.onCollectionsChanged);
+ });
+ it("should re enable stories when top stories is turned on", async () => {
+ sandbox.stub(feed, "refreshAll").returns(Promise.resolve());
+ feed.loaded = true;
+ setPref(CONFIG_PREF_NAME, {
+ enabled: true,
+ });
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "feeds.section.topstories", value: true },
+ });
+
+ assert.calledOnce(feed.refreshAll);
+ });
+ });
+
+ describe("#onAction: SYSTEM_TICK", () => {
+ it("should not refresh if DiscoveryStream has not been loaded", async () => {
+ sandbox.stub(feed, "refreshAll").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(feed.refreshAll);
+ });
+
+ it("should not refresh if no caches are expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(false);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(feed.refreshAll);
+ });
+
+ it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(feed.refreshAll);
+ });
+
+ it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.SYSTEM_TICK });
+ assert.calledWith(feed.refreshAll, { updateOpenTabs: false });
+ });
+ });
+
+ describe("#onCollectionsChanged", () => {
+ it("should call loadLayout when Pocket config changes", async () => {
+ sandbox.stub(feed, "loadLayout").callsFake(dispatch => dispatch("foo"));
+ sandbox.stub(feed.store, "dispatch");
+ await feed.onCollectionsChanged();
+ assert.calledOnce(feed.loadLayout);
+ assert.calledWith(feed.store.dispatch, ac.AlsoToPreloaded("foo"));
+ });
+ });
+
+ describe("#onPrefChange", () => {
+ it("should call loadLayout when Pocket config changes", async () => {
+ sandbox.stub(feed, "loadLayout");
+ feed._prefCache.config = {
+ enabled: true,
+ };
+ await feed.onPrefChange();
+ assert.calledOnce(feed.loadLayout);
+ });
+ });
+
+ describe("#onAction: PREF_SHOW_SPONSORED", () => {
+ it("should call loadSpocs when preference changes", async () => {
+ sandbox.stub(feed, "loadSpocs").resolves();
+ sandbox.stub(feed.store, "dispatch");
+
+ await feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored" },
+ });
+
+ assert.calledOnce(feed.loadSpocs);
+ const [dispatchFn] = feed.loadSpocs.firstCall.args;
+ dispatchFn({});
+ assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({}));
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_IDLE_DAILY", () => {
+ it("should trigger idle-daily observer", async () => {
+ sandbox.stub(global.Services.obs, "notifyObservers").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY,
+ });
+ assert.calledWith(
+ global.Services.obs.notifyObservers,
+ null,
+ "idle-daily"
+ );
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => {
+ it("should fire remote settings pollChanges", async () => {
+ sandbox.stub(global.RemoteSettings, "pollChanges").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_SYNC_RS,
+ });
+ assert.calledOnce(global.RemoteSettings.pollChanges);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => {
+ it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ setPref(CONFIG_PREF_NAME, { enabled: true });
+
+ await feed.onAction({ type: at.INIT });
+
+ sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true);
+ sandbox.stub(feed, "refreshAll").resolves();
+
+ await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK });
+ assert.calledOnce(feed.refreshAll);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => {
+ it("should fire resetCache", async () => {
+ sandbox.stub(feed, "resetContentCache").returns();
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE,
+ });
+ assert.calledOnce(feed.resetContentCache);
+ });
+ });
+
+ describe("#spocsCacheUpdateTime", () => {
+ it("should call setupSpocsCacheUpdateTime", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ sandbox.spy(feed, "setupSpocsCacheUpdateTime");
+ const cacheTime = feed.spocsCacheUpdateTime;
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ assert.equal(cacheTime, defaultCacheTime);
+ assert.calledOnce(feed.setupSpocsCacheUpdateTime);
+ });
+ it("should return _spocsCacheUpdateTime", () => {
+ sandbox.spy(feed, "setupSpocsCacheUpdateTime");
+ const testCacheTime = 123;
+ feed._spocsCacheUpdateTime = testCacheTime;
+ const cacheTime = feed.spocsCacheUpdateTime;
+ // Ensure _spocsCacheUpdateTime was not changed.
+ assert.equal(feed._spocsCacheUpdateTime, testCacheTime);
+ assert.equal(cacheTime, testCacheTime);
+ assert.notCalled(feed.setupSpocsCacheUpdateTime);
+ });
+ });
+
+ describe("#setupSpocsCacheUpdateTime", () => {
+ it("should set _spocsCacheUpdateTime with default value", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with min", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 1,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with max", () => {
+ const defaultCacheTime = 30 * 60 * 1000;
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 31,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime);
+ });
+ it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ spocsCacheTimeout: 20,
+ },
+ },
+ },
+ });
+ feed.setupSpocsCacheUpdateTime();
+ assert.equal(feed._spocsCacheUpdateTime, 20 * 60 * 1000);
+ });
+ });
+
+ describe("#isExpired", () => {
+ it("should throw if the key is not valid", () => {
+ assert.throws(() => {
+ feed.isExpired({}, "foo");
+ });
+ });
+ it("should return false for layout on startup for content under 1 week", () => {
+ const layout = { lastUpdated: Date.now() };
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isFalse(result);
+ });
+ it("should return true for layout for isStartup=false after 30 mins", () => {
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(THIRTY_MINUTES + 1);
+ const result = feed.isExpired({ cachedData: { layout }, key: "layout" });
+
+ assert.isTrue(result);
+ });
+ it("should return true for layout on startup for content over 1 week", () => {
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(ONE_WEEK + 1);
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isTrue(result);
+ });
+ it("should return false for hardcoded layout on startup for content over 1 week", () => {
+ feed._prefCache.config = {
+ hardcoded_layout: true,
+ };
+ const layout = { lastUpdated: Date.now() };
+ clock.tick(ONE_WEEK + 1);
+ const result = feed.isExpired({
+ cachedData: { layout },
+ key: "layout",
+ isStartup: true,
+ });
+
+ assert.isFalse(result);
+ });
+ });
+
+ describe("#checkIfAnyCacheExpired", () => {
+ let cache;
+ beforeEach(() => {
+ cache = {
+ layout: { lastUpdated: Date.now() },
+ feeds: { "foo.com": { lastUpdated: Date.now() } },
+ spocs: { lastUpdated: Date.now() },
+ };
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ sandbox.stub(feed.cache, "get").resolves(cache);
+ });
+
+ it("should return false if nothing in the cache is expired", async () => {
+ const result = await feed.checkIfAnyCacheExpired();
+ assert.isFalse(result);
+ });
+
+ it("should return true if .layout is missing", async () => {
+ delete cache.layout;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if .layout is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.feeds["foo.com"].lastUpdate = Date.now();
+ cache.spocs.lastUpdate = Date.now();
+
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+
+ it("should return true if .spocs is missing", async () => {
+ delete cache.spocs;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if .spocs is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.layout.lastUpdated = Date.now();
+ cache.feeds["foo.com"].lastUpdate = Date.now();
+
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+
+ it("should return true if .feeds is missing", async () => {
+ delete cache.feeds;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if data for .feeds[url] is missing", async () => {
+ cache.feeds["foo.com"] = null;
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ it("should return true if data for .feeds[url] is expired", async () => {
+ clock.tick(THIRTY_MINUTES + 1);
+ // Update other caches we aren't testing
+ cache.layout.lastUpdated = Date.now();
+ cache.spocs.lastUpdate = Date.now();
+ assert.isTrue(await feed.checkIfAnyCacheExpired());
+ });
+ });
+
+ describe("#refreshAll", () => {
+ beforeEach(() => {
+ sandbox.stub(feed, "loadLayout").resolves();
+ sandbox.stub(feed, "loadComponentFeeds").resolves();
+ sandbox.stub(feed, "loadSpocs").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ Object.defineProperty(feed, "showSpocs", { get: () => true });
+ });
+
+ it("should call layout, component, spocs update and telemetry reporting functions", async () => {
+ await feed.refreshAll();
+
+ assert.calledOnce(feed.loadLayout);
+ assert.calledOnce(feed.loadComponentFeeds);
+ assert.calledOnce(feed.loadSpocs);
+ });
+ it("should pass in dispatch wrapped with broadcast if options.updateOpenTabs is true", async () => {
+ await feed.refreshAll({ updateOpenTabs: true });
+ [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
+ assert.calledOnce(fn);
+ const result = fn.firstCall.args[0]({ type: "FOO" });
+ assert.isTrue(au.isBroadcastToContent(result));
+ });
+ });
+ it("should pass in dispatch with regular actions if options.updateOpenTabs is false", async () => {
+ await feed.refreshAll({ updateOpenTabs: false });
+ [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => {
+ assert.calledOnce(fn);
+ const result = fn.firstCall.args[0]({ type: "FOO" });
+ assert.deepEqual(result, { type: "FOO" });
+ });
+ });
+ it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => {
+ feed.loadComponentFeeds.rejects("loadComponentFeeds error");
+ feed.loadSpocs.rejects("loadSpocs error");
+
+ await feed.enable();
+
+ assert.isTrue(feed.loaded);
+ });
+ it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => {
+ sandbox.stub(global.Promise, "all").resolves();
+
+ await feed.refreshAll();
+
+ assert.calledOnce(global.Promise.all);
+ const { args } = global.Promise.all.firstCall;
+ assert.equal(args[0].length, 2);
+ });
+ describe("test startup cache behaviour", () => {
+ beforeEach(() => {
+ feed._maybeUpdateCachedData.restore();
+ sandbox.stub(feed.cache, "set").resolves();
+ });
+ it("should refresh layout on startup if it was served from cache", async () => {
+ feed.loadLayout.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ layout: { lastUpdated: Date.now(), layout: {} } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} });
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the store
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_LAYOUT_UPDATE
+ );
+ });
+ it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => {
+ feed.loadLayout.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ layout: { lastUpdated: Date.now(), layout: {} } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} });
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should refresh spocs on startup if it was served from cache", async () => {
+ feed.loadSpocs.restore();
+ sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]);
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ spocs: { lastUpdated: Date.now() } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
+ clock.tick(THIRTY_MINUTES + 1);
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the store
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_SPOCS_UPDATE
+ );
+ });
+ it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => {
+ feed.loadSpocs.restore();
+ sandbox
+ .stub(feed.cache, "get")
+ .resolves({ spocs: { lastUpdated: Date.now() } });
+ sandbox.stub(feed, "fetchFromEndpoint").resolves("data");
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.notCalled(feed.fetchFromEndpoint);
+ });
+ it("should refresh feeds on startup if it was served from cache", async () => {
+ feed.loadComponentFeeds.restore();
+
+ const fakeComponents = { components: [{ feed: { url: "foo.com" } }] };
+ const fakeLayout = [fakeComponents];
+ const fakeDiscoveryStream = {
+ DiscoveryStream: {
+ layout: fakeLayout,
+ },
+ Prefs: {
+ values: {
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "cleanUpTopRecImpressionPref").callsFake(val => val);
+
+ const fakeCache = {
+ feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } },
+ };
+ sandbox.stub(feed.cache, "get").resolves(fakeCache);
+ clock.tick(THIRTY_MINUTES + 1);
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ recommendations: "data",
+ settings: {
+ recsExpireTime: 1,
+ },
+ });
+
+ await feed.refreshAll({ isStartup: true });
+
+ assert.calledOnce(feed.fetchFromEndpoint);
+ // Once from cache, once to update the feed, once to update that all feeds are done.
+ assert.calledThrice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ at.DISCOVERY_STREAM_FEEDS_UPDATE
+ );
+ });
+ });
+ });
+
+ describe("#scoreFeeds", () => {
+ it("should score feeds and set cache, and dispatch", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ const recsExpireTime = 5600;
+ const fakeImpressions = {
+ first: Date.now() - recsExpireTime * 1000,
+ third: Date.now(),
+ };
+ sandbox.stub(feed, "readDataPref").returns(fakeImpressions);
+ const fakeFeeds = {
+ data: {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "first",
+ item_score: 0.7,
+ },
+ {
+ id: "second",
+ item_score: 0.6,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "third",
+ item_score: 0.4,
+ },
+ {
+ id: "fourth",
+ item_score: 0.6,
+ },
+ {
+ id: "fifth",
+ item_score: 0.8,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ },
+ };
+ const feedsTestResult = {
+ "https://foo.com": {
+ data: {
+ recommendations: [
+ {
+ id: "second",
+ item_score: 0.6,
+ score: 0.6,
+ },
+ {
+ id: "first",
+ item_score: 0.7,
+ score: 0.7,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ "https://bar.com": {
+ data: {
+ recommendations: [
+ {
+ id: "fifth",
+ item_score: 0.8,
+ score: 0.8,
+ },
+ {
+ id: "fourth",
+ item_score: 0.6,
+ score: 0.6,
+ },
+ {
+ id: "third",
+ item_score: 0.4,
+ score: 0.4,
+ },
+ ],
+ settings: {
+ recsExpireTime,
+ },
+ },
+ },
+ };
+
+ await feed.scoreFeeds(fakeFeeds);
+
+ assert.calledWith(feed.cache.set, "feeds", feedsTestResult);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_FEED_UPDATE
+ );
+ assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, {
+ url: "https://foo.com",
+ feed: feedsTestResult["https://foo.com"],
+ });
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].type,
+ at.DISCOVERY_STREAM_FEED_UPDATE
+ );
+ assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, {
+ url: "https://bar.com",
+ feed: feedsTestResult["https://bar.com"],
+ });
+ });
+ });
+
+ describe("#scoreSpocs", () => {
+ it("should score spocs and set cache, dispatch", async () => {
+ sandbox.stub(feed.cache, "set").resolves();
+ sandbox.spy(feed.store, "dispatch");
+ const fakeDiscoveryStream = {
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": false,
+ },
+ },
+ DiscoveryStream: {
+ spocs: {
+ placements: [
+ { name: "placement1" },
+ { name: "placement2" },
+ { name: "placement3" },
+ ],
+ },
+ },
+ };
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ const fakeSpocs = {
+ lastUpdated: 1234,
+ data: {
+ placement1: {
+ items: [
+ {
+ item_score: 0.6,
+ },
+ {
+ item_score: 0.4,
+ },
+ {
+ item_score: 0.8,
+ },
+ ],
+ },
+ placement2: {
+ items: [
+ {
+ item_score: 0.6,
+ },
+ {
+ item_score: 0.8,
+ },
+ ],
+ },
+ placement3: { items: [] },
+ },
+ };
+
+ await feed.scoreSpocs(fakeSpocs);
+
+ const spocsTestResult = {
+ lastUpdated: 1234,
+ spocs: {
+ placement1: {
+ items: [
+ {
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ score: 0.6,
+ item_score: 0.6,
+ },
+ {
+ score: 0.4,
+ item_score: 0.4,
+ },
+ ],
+ },
+ placement2: {
+ items: [
+ {
+ score: 0.8,
+ item_score: 0.8,
+ },
+ {
+ score: 0.6,
+ item_score: 0.6,
+ },
+ ],
+ },
+ placement3: { items: [] },
+ },
+ };
+ assert.calledWith(feed.cache.set, "spocs", spocsTestResult);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.DISCOVERY_STREAM_SPOCS_UPDATE
+ );
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data,
+ spocsTestResult
+ );
+ });
+ });
+
+ describe("#scoreContent", () => {
+ it("should call scoreFeeds and scoreSpocs if loaded", async () => {
+ const fakeDiscoveryStream = {
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ DiscoveryStream: {
+ feeds: { loaded: false },
+ spocs: { loaded: false },
+ },
+ };
+
+ sandbox.stub(feed, "scoreFeeds").resolves();
+ sandbox.stub(feed, "scoreSpocs").resolves();
+ sandbox.stub(feed, "refreshContent").resolves();
+ sandbox.stub(feed, "loadPersonalizationScoresCache").resolves();
+ sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream);
+ sandbox.stub(feed, "_checkExpirationPerComponent").resolves({
+ feeds: true,
+ spocs: true,
+ });
+
+ await feed.refreshAll();
+
+ assert.notCalled(feed.scoreFeeds);
+ assert.notCalled(feed.scoreSpocs);
+
+ fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true;
+ fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true;
+
+ await feed.refreshAll();
+
+ assert.calledOnce(feed.scoreFeeds);
+ assert.calledOnce(feed.scoreSpocs);
+ });
+ });
+
+ describe("#loadPersonalizationScoresCache", () => {
+ it("should create a personalization provider from cached scores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ const fakeCache = {
+ personalization: {
+ scores: 123,
+ _timestamp: 456,
+ },
+ };
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+
+ await feed.loadPersonalizationScoresCache();
+
+ assert.equal(feed.personalizationLastUpdated, 456);
+ });
+ });
+
+ describe("#observe", () => {
+ it("should call updatePersonalizationScores on idle daily", async () => {
+ sandbox.stub(feed, "updatePersonalizationScores").returns();
+ feed.observe(null, "idle-daily");
+ assert.calledOnce(feed.updatePersonalizationScores);
+ });
+ it("should call configReset on Pocket button pref change", async () => {
+ sandbox.stub(feed, "configReset").returns();
+ feed.observe(null, "nsPref:changed", "extensions.pocket.enabled");
+ assert.calledOnce(feed.configReset);
+ });
+ });
+
+ describe("#updatePersonalizationScores", () => {
+ it("should update recommendationProvider on updatePersonalizationScores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ sandbox.stub(feed.recommendationProvider, "init").returns();
+
+ await feed.updatePersonalizationScores();
+
+ assert.deepEqual(feed.recommendationProvider.provider.getScores(), {
+ interestConfig: undefined,
+ interestVector: undefined,
+ });
+ });
+ it("should not update recommendationProvider on updatePersonalizationScores", async () => {
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ "discoverystream.personalization.enabled": false,
+ },
+ },
+ });
+ await feed.updatePersonalizationScores();
+
+ assert.isTrue(!feed.recommendationProvider.provider);
+ });
+ });
+ describe("#scoreItem", () => {
+ it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => {
+ const item = {
+ item_score: 0.6,
+ };
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ pocketConfig: {
+ recsPersonalized: true,
+ spocsPersonalized: true,
+ },
+ "discoverystream.personalization.enabled": true,
+ "feeds.section.topstories": true,
+ "feeds.system.topstories": true,
+ },
+ },
+ });
+ feed.recommendationProvider.calculateItemRelevanceScore = sandbox
+ .stub()
+ .returns();
+ const result = await feed.scoreItem(item, true);
+ assert.calledOnce(
+ feed.recommendationProvider.calculateItemRelevanceScore
+ );
+ assert.equal(result.score, 0.6);
+ });
+ it("should fallback to score 1 without an initial score", async () => {
+ const item = {};
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.spocs.personalized": true,
+ "discoverystream.recs.personalized": true,
+ "discoverystream.personalization.enabled": true,
+ },
+ },
+ });
+ feed.recommendationProvider.calculateItemRelevanceScore = sandbox
+ .stub()
+ .returns();
+ const result = await feed.scoreItem(item, true);
+ assert.equal(result.score, 1);
+ });
+ });
+ describe("new proxy feed", () => {
+ beforeEach(() => {
+ feed.store = createStore(combineReducers(reducers), {
+ Prefs: {
+ values: {
+ pocketConfig: { regionBffConfig: "DE" },
+ },
+ },
+ });
+ sandbox.stub(global.Region, "home").get(() => "DE");
+ globals.set("NimbusFeatures", {
+ saveToPocket: {
+ getVariable: sandbox.stub(),
+ },
+ });
+ global.NimbusFeatures.saveToPocket.getVariable
+ .withArgs("bffApi")
+ .returns("bffApi");
+ global.NimbusFeatures.saveToPocket.getVariable
+ .withArgs("oAuthConsumerKeyBff")
+ .returns("oAuthConsumerKeyBff");
+ });
+ it("should return true with isBff", async () => {
+ assert.isUndefined(feed._isBff);
+ assert.isTrue(feed.isBff);
+ assert.isTrue(feed._isBff);
+ });
+ it("should update to new feed url", async () => {
+ await feed.loadLayout(feed.store.dispatch);
+ const { layout } = feed.store.getState().DiscoveryStream;
+ assert.equal(
+ layout[0].components[2].feed.url,
+ "https://bffApi/desktop/v1/recommendations?locale=$locale&region=$region&count=30"
+ );
+ });
+ it("should fetch proper data from getComponentFeed", async () => {
+ const fakeCache = {};
+ sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
+ sandbox.stub(feed, "rotate").callsFake(val => val);
+ sandbox
+ .stub(feed, "scoreItems")
+ .callsFake(val => ({ data: val, filtered: [] }));
+ sandbox.stub(feed, "fetchFromEndpoint").resolves({
+ data: [
+ {
+ tileId: 1234,
+ url: "url",
+ title: "title",
+ excerpt: "excerpt",
+ publisher: "publisher",
+ imageUrl: "imageUrl",
+ },
+ ],
+ });
+
+ const feedData = await feed.getComponentFeed("url");
+ assert.deepEqual(feedData, {
+ lastUpdated: 0,
+ data: {
+ settings: {},
+ recommendations: [
+ {
+ id: 1234,
+ url: "url",
+ title: "title",
+ excerpt: "excerpt",
+ publisher: "publisher",
+ raw_image_src: "imageUrl",
+ },
+ ],
+ status: "success",
+ },
+ });
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[0], "url");
+ assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "GET");
+ assert.equal(
+ feed.fetchFromEndpoint.firstCall.args[1].headers.get("consumer_key"),
+ "oAuthConsumerKeyBff"
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js
new file mode 100644
index 0000000000..0dfdff548b
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js
@@ -0,0 +1,373 @@
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { DownloadsManager } from "lib/DownloadsManager.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("Downloads Manager", () => {
+ let downloadsManager;
+ let globals;
+ const DOWNLOAD_URL = "https://site.com/download.mov";
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ global.Cc["@mozilla.org/timer;1"] = {
+ createInstance() {
+ return {
+ initWithCallback: sinon.stub().callsFake(callback => callback()),
+ cancel: sinon.spy(),
+ };
+ },
+ };
+
+ globals.set("DownloadsCommon", {
+ getData: sinon.stub().returns({
+ addView: sinon.stub(),
+ removeView: sinon.stub(),
+ }),
+ copyDownloadLink: sinon.stub(),
+ deleteDownload: sinon.stub().returns(Promise.resolve()),
+ openDownload: sinon.stub(),
+ showDownloadedFile: sinon.stub(),
+ });
+
+ downloadsManager = new DownloadsManager();
+ downloadsManager.init({ dispatch() {} });
+ downloadsManager.onDownloadAdded({
+ source: { url: DOWNLOAD_URL },
+ endTime: Date.now(),
+ target: { path: "/path/to/download.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ });
+ assert.ok(downloadsManager._downloadItems.has(DOWNLOAD_URL));
+
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked() {} } });
+ });
+ afterEach(() => {
+ downloadsManager._downloadItems.clear();
+ globals.restore();
+ });
+ describe("#init", () => {
+ it("should add a DownloadsCommon view on init", () => {
+ downloadsManager.init({ dispatch() {} });
+ assert.calledTwice(global.DownloadsCommon.getData().addView);
+ });
+ });
+ describe("#onAction", () => {
+ it("should copy the file on COPY_DOWNLOAD_LINK", () => {
+ downloadsManager.onAction({
+ type: at.COPY_DOWNLOAD_LINK,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.copyDownloadLink);
+ });
+ it("should remove the file on REMOVE_DOWNLOAD_FILE", () => {
+ downloadsManager.onAction({
+ type: at.REMOVE_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.deleteDownload);
+ });
+ it("should show the file on SHOW_DOWNLOAD_FILE", () => {
+ downloadsManager.onAction({
+ type: at.SHOW_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL },
+ });
+ assert.calledOnce(global.DownloadsCommon.showDownloadedFile);
+ });
+ it("should open the file on OPEN_DOWNLOAD_FILE if the type is download", () => {
+ downloadsManager.onAction({
+ type: at.OPEN_DOWNLOAD_FILE,
+ data: { url: DOWNLOAD_URL, type: "download" },
+ _target: { browser: {} },
+ });
+ assert.calledOnce(global.DownloadsCommon.openDownload);
+ });
+ it("should copy the file on UNINIT", () => {
+ // DownloadsManager._downloadData needs to exist first
+ downloadsManager.onAction({ type: at.UNINIT });
+ assert.calledOnce(global.DownloadsCommon.getData().removeView);
+ });
+ it("should not execute a download command if we do not have the correct url", () => {
+ downloadsManager.onAction({
+ type: at.SHOW_DOWNLOAD_FILE,
+ data: { url: "unknown_url" },
+ });
+ assert.notCalled(global.DownloadsCommon.showDownloadedFile);
+ });
+ });
+ describe("#onDownloadAdded", () => {
+ let newDownload;
+ beforeEach(() => {
+ downloadsManager._downloadItems.clear();
+ newDownload = {
+ source: { url: "https://site.com/newDownload.mov" },
+ endTime: Date.now(),
+ target: { path: "/path/to/newDownload.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ });
+ afterEach(() => {
+ downloadsManager._downloadItems.clear();
+ });
+ it("should add a download on onDownloadAdded", () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ assert.ok(
+ downloadsManager._downloadItems.has("https://site.com/newDownload.mov")
+ );
+ });
+ it("should not add a download if it already exists", () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = downloadsManager._downloadItems;
+ assert.equal(results.size, 1);
+ });
+ it("should not return any downloads if no threshold is provided", async () => {
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(null, {});
+ assert.equal(results.length, 0);
+ });
+ it("should stop at numItems when it found one it's looking for", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now(),
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(aDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 1,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, aDownload.source.url);
+ });
+ it("should get all the downloads younger than the threshold provided", async () => {
+ const oldDownload = {
+ source: { url: "https://site.com/oldDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/oldDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ // Add an old download (older than 36 hours in this case)
+ downloadsManager.onDownloadAdded(oldDownload);
+ downloadsManager.onDownloadAdded(newDownload);
+ const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;
+ const results = await downloadsManager.getDownloads(
+ RECENT_DOWNLOAD_THRESHOLD,
+ { numItems: 5, onlySucceeded: true, onlyExists: true }
+ );
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should dispatch DOWNLOAD_CHANGED when adding a download", () => {
+ downloadsManager._store.dispatch = sinon.spy();
+ downloadsManager._downloadTimer = null; // Nuke the timer
+ downloadsManager.onDownloadAdded(newDownload);
+ assert.calledOnce(downloadsManager._store.dispatch);
+ });
+ it("should refresh the downloads if onlyExists is true", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: () => {},
+ };
+ sinon.stub(aDownload, "refresh").returns(Promise.resolve());
+ downloadsManager.onDownloadAdded(aDownload);
+ await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.calledOnce(aDownload.refresh);
+ });
+ it("should not refresh the downloads if onlyExists is false (by default)", async () => {
+ const aDownload = {
+ source: { url: "https://site.com/aDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/aDownload.pdf", exists: true },
+ succeeded: true,
+ refresh: () => {},
+ };
+ sinon.stub(aDownload, "refresh").returns(Promise.resolve());
+ downloadsManager.onDownloadAdded(aDownload);
+ await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.notCalled(aDownload.refresh);
+ });
+ it("should only return downloads that exist if specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should return all downloads that either exist or don't exist if not specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.equal(results.length, 2);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, nonExistantDownload.source.url);
+ });
+ it("should return only unblocked downloads", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: false },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ globals.set("NewTabUtils", {
+ blockedLinks: {
+ isBlocked: item => item.url === nonExistantDownload.source.url,
+ },
+ });
+
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+
+ assert.equal(results.length, 1);
+ assert.propertyVal(results[0], "url", newDownload.source.url);
+ });
+ it("should only return downloads that were successful if specified", async () => {
+ const nonSuccessfulDownload = {
+ source: { url: "https://site.com/nonSuccessfulDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonSuccessfulDownload.pdf", exists: false },
+ succeeded: false,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonSuccessfulDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].url, newDownload.source.url);
+ });
+ it("should return all downloads that were either successful or not if not specified", async () => {
+ const nonExistantDownload = {
+ source: { url: "https://site.com/nonExistantDownload.pdf" },
+ endTime: Date.now() - 40 * 60 * 60 * 1000,
+ target: { path: "/path/to/nonExistantDownload.pdf", exists: true },
+ succeeded: false,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ downloadsManager.onDownloadAdded(nonExistantDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ });
+ assert.equal(results.length, 2);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, nonExistantDownload.source.url);
+ });
+ it("should sort the downloads by recency", async () => {
+ const olderDownload1 = {
+ source: { url: "https://site.com/oldDownload1.pdf" },
+ endTime: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago
+ target: { path: "/path/to/oldDownload1.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ const olderDownload2 = {
+ source: { url: "https://site.com/oldDownload2.pdf" },
+ endTime: Date.now() - 60 * 60 * 1000, // 1 hour ago
+ target: { path: "/path/to/oldDownload2.pdf", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ // Add some older downloads and check that they are in order
+ downloadsManager.onDownloadAdded(olderDownload1);
+ downloadsManager.onDownloadAdded(olderDownload2);
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 3);
+ assert.equal(results[0].url, newDownload.source.url);
+ assert.equal(results[1].url, olderDownload2.source.url);
+ assert.equal(results[2].url, olderDownload1.source.url);
+ });
+ it("should format the description properly if there is no file type", async () => {
+ newDownload.target.path = null;
+ downloadsManager.onDownloadAdded(newDownload);
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ onlySucceeded: true,
+ onlyExists: true,
+ });
+ assert.equal(results.length, 1);
+ assert.equal(results[0].description, "1.5 MB"); // see unit-entry.js to see where this comes from
+ });
+ });
+ describe("#onDownloadRemoved", () => {
+ let newDownload;
+ beforeEach(() => {
+ downloadsManager._downloadItems.clear();
+ newDownload = {
+ source: { url: "https://site.com/removeMe.mov" },
+ endTime: Date.now(),
+ target: { path: "/path/to/removeMe.mov", exists: true },
+ succeeded: true,
+ refresh: async () => {},
+ };
+ downloadsManager.onDownloadAdded(newDownload);
+ });
+ it("should remove a download if it exists on onDownloadRemoved", async () => {
+ downloadsManager.onDownloadRemoved({
+ source: { url: "https://site.com/removeMe.mov" },
+ });
+ const results = await downloadsManager.getDownloads(Infinity, {
+ numItems: 5,
+ });
+ assert.deepEqual(results, []);
+ });
+ it("should dispatch DOWNLOAD_CHANGED when removing a download", () => {
+ downloadsManager._store.dispatch = sinon.spy();
+ downloadsManager.onDownloadRemoved({
+ source: { url: "https://site.com/removeMe.mov" },
+ });
+ assert.calledOnce(downloadsManager._store.dispatch);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/FaviconFeed.test.js b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js
new file mode 100644
index 0000000000..6476e2a3be
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js
@@ -0,0 +1,233 @@
+"use strict";
+import { FaviconFeed, fetchIconFromRedirects } from "lib/FaviconFeed.jsm";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+const FAKE_ENDPOINT = "https://foo.com/";
+
+describe("FaviconFeed", () => {
+ let feed;
+ let globals;
+ let sandbox;
+ let clock;
+ let siteIconsPref;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ globals.set("PlacesUtils", {
+ favicons: {
+ setAndFetchFaviconForPage: sandbox.spy(),
+ getFaviconDataForPage: () => Promise.resolve(null),
+ FAVICON_LOAD_NON_PRIVATE: 1,
+ },
+ history: {
+ TRANSITIONS: {
+ REDIRECT_TEMPORARY: 1,
+ REDIRECT_PERMANENT: 2,
+ },
+ },
+ });
+ globals.set("NewTabUtils", {
+ activityStreamProvider: { executePlacesQuery: () => Promise.resolve([]) },
+ });
+ siteIconsPref = true;
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs("browser.chrome.site_icons")
+ .callsFake(() => siteIconsPref);
+
+ feed = new FaviconFeed();
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: { values: { "tippyTop.service.endpoint": FAKE_ENDPOINT } },
+ },
+ };
+ });
+ afterEach(() => {
+ clock.restore();
+ globals.restore();
+ });
+
+ it("should create a FaviconFeed", () => {
+ assert.instanceOf(feed, FaviconFeed);
+ });
+
+ describe("#fetchIcon", () => {
+ let domain;
+ let url;
+ beforeEach(() => {
+ domain = "mozilla.org";
+ url = `https://${domain}/`;
+ feed.getSite = sandbox
+ .stub()
+ .returns(Promise.resolve({ domain, image_url: `${url}/icon.png` }));
+ feed._queryForRedirects.clear();
+ });
+
+ it("should setAndFetchFaviconForPage if the url is in the TippyTop data", async () => {
+ await feed.fetchIcon(url);
+
+ assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ assert.calledWith(
+ global.PlacesUtils.favicons.setAndFetchFaviconForPage,
+ sinon.match({ spec: url }),
+ { ref: "tippytop", spec: `${url}/icon.png` },
+ false,
+ global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ undefined
+ );
+ });
+ it("should NOT setAndFetchFaviconForPage if site_icons pref is false", async () => {
+ siteIconsPref = false;
+
+ await feed.fetchIcon(url);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the url is NOT in the TippyTop data", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ await feed.fetchIcon("https://example.com");
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should issue a fetchIconFromRedirects if the url is NOT in the TippyTop data", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+
+ assert.calledOnce(global.Services.tm.idleDispatchToMainThread);
+ });
+ it("should only issue fetchIconFromRedirects once on the same url", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+ await feed.fetchIcon("https://example.com");
+
+ assert.calledOnce(global.Services.tm.idleDispatchToMainThread);
+ });
+ it("should issue fetchIconFromRedirects twice on two different urls", async () => {
+ feed.getSite = sandbox.stub().returns(Promise.resolve(null));
+ sandbox.spy(global.Services.tm, "idleDispatchToMainThread");
+
+ await feed.fetchIcon("https://example.com");
+ await feed.fetchIcon("https://another.example.com");
+
+ assert.calledTwice(global.Services.tm.idleDispatchToMainThread);
+ });
+ });
+
+ describe("#getSite", () => {
+ it("should return site data if RemoteSettings has an entry for the domain", async () => {
+ const get = () =>
+ Promise.resolve([{ domain: "example.com", image_url: "foo.img" }]);
+ feed._tippyTop = { get };
+ const site = await feed.getSite("example.com");
+ assert.equal(site.domain, "example.com");
+ });
+ it("should return null if RemoteSettings doesn't have an entry for the domain", async () => {
+ const get = () => Promise.resolve([]);
+ feed._tippyTop = { get };
+ const site = await feed.getSite("example.com");
+ assert.isNull(site);
+ });
+ it("should lazy init _tippyTop", async () => {
+ assert.isUndefined(feed._tippyTop);
+ await feed.getSite("example.com");
+ assert.ok(feed._tippyTop);
+ });
+ });
+
+ describe("#onAction", () => {
+ it("should fetchIcon on RICH_ICON_MISSING", async () => {
+ feed.fetchIcon = sinon.spy();
+ const url = "https://mozilla.org";
+ feed.onAction({ type: at.RICH_ICON_MISSING, data: { url } });
+ assert.calledOnce(feed.fetchIcon);
+ assert.calledWith(feed.fetchIcon, url);
+ });
+ });
+
+ describe("#fetchIconFromRedirects", () => {
+ let domain;
+ let url;
+ let iconUrl;
+
+ beforeEach(() => {
+ domain = "mozilla.org";
+ url = `https://${domain}/`;
+ iconUrl = `${url}/icon.png`;
+ });
+ it("should setAndFetchFaviconForPage if the url was redirected with a icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, { spec: iconUrl }, 0, null, null, 96);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ assert.calledWith(
+ global.PlacesUtils.favicons.setAndFetchFaviconForPage,
+ sinon.match({ spec: domain }),
+ { spec: iconUrl },
+ false,
+ global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ undefined
+ );
+ });
+ it("should NOT setAndFetchFaviconForPage if the url doesn't have any redirect", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([]);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the original url doesn't have a icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, null, null, null, null, null);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ it("should NOT setAndFetchFaviconForPage if the original url doesn't have a rich icon", async () => {
+ sandbox
+ .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery")
+ .resolves([
+ { visit_id: 1, url: domain },
+ { visit_id: 2, url },
+ ]);
+ sandbox
+ .stub(global.PlacesUtils.favicons, "getFaviconDataForPage")
+ .callsArgWith(1, { spec: iconUrl }, 0, null, null, 16);
+
+ await fetchIconFromRedirects(domain);
+
+ assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/FilterAdult.test.js b/browser/components/newtab/test/unit/lib/FilterAdult.test.js
new file mode 100644
index 0000000000..e5d15a3fb0
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/FilterAdult.test.js
@@ -0,0 +1,112 @@
+import { FilterAdult } from "lib/FilterAdult.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("FilterAdult", () => {
+ let hashStub;
+ let hashValue;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ hashStub = {
+ finish: sinon.stub().callsFake(() => hashValue),
+ init: sinon.stub(),
+ update: sinon.stub(),
+ };
+ globals.set("Cc", {
+ "@mozilla.org/security/hash;1": {
+ createInstance() {
+ return hashStub;
+ },
+ },
+ });
+ globals.set("gFilterAdultEnabled", true);
+ });
+
+ afterEach(() => {
+ hashValue = "";
+ globals.restore();
+ });
+
+ describe("filter", () => {
+ it("should default to include on unexpected urls", () => {
+ const empty = {};
+
+ const result = FilterAdult.filter([empty]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], empty);
+ });
+ it("should not filter out non-adult urls", () => {
+ const link = { url: "https://mozilla.org/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], link);
+ });
+ it("should filter out adult urls", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ const link = { url: "https://some-adult-site/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 0);
+ });
+ it("should not filter out adult urls if the preference is turned off", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ globals.set("gFilterAdultEnabled", false);
+ const link = { url: "https://some-adult-site/" };
+
+ const result = FilterAdult.filter([link]);
+
+ assert.equal(result.length, 1);
+ assert.equal(result[0], link);
+ });
+ });
+
+ describe("isAdultUrl", () => {
+ it("should default to false on unexpected urls", () => {
+ const result = FilterAdult.isAdultUrl("");
+
+ assert.equal(result, false);
+ });
+ it("should return false for non-adult urls", () => {
+ const result = FilterAdult.isAdultUrl("https://mozilla.org/");
+
+ assert.equal(result, false);
+ });
+ it("should return true for adult urls", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ const result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, true);
+ });
+ it("should return false for adult urls when the preference is turned off", () => {
+ // Use a hash value that is in the adult set
+ hashValue = "+/UCpAhZhz368iGioEO8aQ==";
+ globals.set("gFilterAdultEnabled", false);
+ const result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, false);
+ });
+
+ describe("test functions", () => {
+ it("should add and remove a filter in the adult list", () => {
+ // Use a hash value that is in the adult set
+ FilterAdult.addDomainToList("https://some-adult-site/");
+ let result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, true);
+
+ FilterAdult.removeDomainFromList("https://some-adult-site/");
+ result = FilterAdult.isAdultUrl("https://some-adult-site/");
+
+ assert.equal(result, false);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js
new file mode 100644
index 0000000000..f0cd2450b7
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js
@@ -0,0 +1,822 @@
+"use strict";
+
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { Dedupe } from "common/Dedupe.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import injector from "inject!lib/HighlightsFeed.jsm";
+import { Screenshots } from "lib/Screenshots.jsm";
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+const FAKE_LINKS = new Array(20)
+ .fill(null)
+ .map((v, i) => ({ url: `http://www.site${i}.com` }));
+const FAKE_IMAGE = "data123";
+
+describe("Highlights Feed", () => {
+ let HighlightsFeed;
+ let SECTION_ID;
+ let SYNC_BOOKMARKS_FINISHED_EVENT;
+ let BOOKMARKS_RESTORE_SUCCESS_EVENT;
+ let BOOKMARKS_RESTORE_FAILED_EVENT;
+ let feed;
+ let globals;
+ let sandbox;
+ let links;
+ let fakeScreenshot;
+ let fakeNewTabUtils;
+ let filterAdultStub;
+ let sectionsManagerStub;
+ let downloadsManagerStub;
+ let shortURLStub;
+ let fakePageThumbs;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeNewTabUtils = {
+ activityStreamLinks: {
+ getHighlights: sandbox.spy(() => Promise.resolve(links)),
+ deletePocketEntry: sandbox.spy(() => Promise.resolve({})),
+ archivePocketEntry: sandbox.spy(() => Promise.resolve({})),
+ },
+ activityStreamProvider: {
+ _processHighlights: sandbox.spy(l => l.slice(0, 1)),
+ },
+ };
+ sectionsManagerStub = {
+ onceInitialized: sinon.stub().callsFake(callback => callback()),
+ enableSection: sinon.spy(),
+ disableSection: sinon.spy(),
+ updateSection: sinon.spy(),
+ updateSectionCard: sinon.spy(),
+ sections: new Map([["highlights", { id: "highlights" }]]),
+ };
+ downloadsManagerStub = sinon.stub().returns({
+ getDownloads: () => [{ url: "https://site.com/download" }],
+ onAction: sinon.spy(),
+ init: sinon.spy(),
+ });
+ fakeScreenshot = {
+ getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_IMAGE)),
+ maybeCacheScreenshot: Screenshots.maybeCacheScreenshot,
+ _shouldGetScreenshots: sinon.stub().returns(true),
+ };
+ filterAdultStub = {
+ filter: sinon.stub().returnsArg(0),
+ };
+ shortURLStub = sinon
+ .stub()
+ .callsFake(site => site.url.match(/\/([^/]+)/)[1]);
+ fakePageThumbs = {
+ addExpirationFilter: sinon.stub(),
+ removeExpirationFilter: sinon.stub(),
+ };
+
+ globals.set({
+ NewTabUtils: fakeNewTabUtils,
+ PageThumbs: fakePageThumbs,
+ gFilterAdultEnabled: false,
+ LinksCache,
+ DownloadsManager: downloadsManagerStub,
+ FilterAdult: filterAdultStub,
+ Screenshots: fakeScreenshot,
+ });
+ ({
+ HighlightsFeed,
+ SECTION_ID,
+ SYNC_BOOKMARKS_FINISHED_EVENT,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT,
+ BOOKMARKS_RESTORE_FAILED_EVENT,
+ } = injector({
+ "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ "common/Dedupe.jsm": { Dedupe },
+ "lib/DownloadsManager.jsm": { DownloadsManager: downloadsManagerStub },
+ }));
+ sandbox.spy(global.Services.obs, "addObserver");
+ sandbox.spy(global.Services.obs, "removeObserver");
+ feed = new HighlightsFeed();
+ feed.store = {
+ dispatch: sinon.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: sinon.stub().callsFake(cb => {
+ cb();
+ return () => {};
+ }),
+ };
+ links = FAKE_LINKS;
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#init", () => {
+ it("should create a HighlightsFeed", () => {
+ assert.instanceOf(feed, HighlightsFeed);
+ });
+ it("should register a expiration filter", () => {
+ assert.calledOnce(fakePageThumbs.addExpirationFilter);
+ });
+ it("should add the sync observer", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ SYNC_BOOKMARKS_FINISHED_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.addObserver,
+ feed,
+ BOOKMARKS_RESTORE_FAILED_EVENT
+ );
+ });
+ it("should call SectionsManager.onceInitialized on INIT", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should enable its section", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
+ });
+ it("should fetch highlights on postInit", () => {
+ feed.fetchHighlights = sinon.spy();
+ feed.postInit();
+ assert.calledOnce(feed.fetchHighlights);
+ });
+ it("should hook up the store for the DownloadsManager", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.downloadsManager.init);
+ });
+ });
+ describe("#observe", () => {
+ beforeEach(() => {
+ feed.fetchHighlights = sinon.spy();
+ });
+ it("should fetch higlights when we are done a sync for bookmarks", () => {
+ feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "bookmarks");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights after a successful import", () => {
+ feed.observe(null, BOOKMARKS_RESTORE_SUCCESS_EVENT, "html");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights after a failed import", () => {
+ feed.observe(null, BOOKMARKS_RESTORE_FAILED_EVENT, "json");
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should not fetch higlights when we are doing a sync for something that is not bookmarks", () => {
+ feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "tabs");
+ assert.notCalled(feed.fetchHighlights);
+ });
+ it("should not fetch higlights for other events", () => {
+ feed.observe(null, "someotherevent", "bookmarks");
+ assert.notCalled(feed.fetchHighlights);
+ });
+ });
+ describe("#filterForThumbnailExpiration", () => {
+ it("should pass rows.urls to the callback provided", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+ feed.store.state.Sections = [
+ { id: "highlights", rows, initialized: true },
+ ];
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(
+ stub,
+ rows.map(r => r.url)
+ );
+ });
+ it("should include preview_image_url (if present) in the callback results", () => {
+ const 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.calledOnce(stub);
+ assert.calledWithExactly(stub, ["foo.com", "bar.com", "bar.jpg"]);
+ });
+ it("should pass an empty array if not initialized", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+ feed.store.state.Sections = [{ rows, initialized: false }];
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, []);
+ });
+ });
+ describe("#fetchHighlights", () => {
+ const fetchHighlights = async options => {
+ await feed.fetchHighlights(options);
+ return sectionsManagerStub.updateSection.firstCall.args[1].rows;
+ };
+ it("should return early if TopSites are not initialised", async () => {
+ 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.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ assert.notCalled(feed.linksCache.request);
+ });
+ it("should return early if Sections are not initialised", async () => {
+ 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.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ assert.notCalled(feed.linksCache.request);
+ });
+ it("should fetch Highlights if TopSites are initialised", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ // fetchHighlights should continue
+ feed.store.state.TopSites.initialized = true;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should chronologically order highlight data types", async () => {
+ 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
+ ];
+ const expectedChronological = [4, 6, 0, 5];
+ const expectedHistory = [1, 2, 3];
+
+ let highlights = await fetchHighlights();
+
+ [...expectedChronological, ...expectedHistory].forEach((link, index) => {
+ assert.propertyVal(
+ highlights[index],
+ "url",
+ links[link].url,
+ `highlight[${index}] should be link[${link}]`
+ );
+ });
+ });
+ it("should fetch Highlights if TopSites are not enabled", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.Prefs.values["feeds.system.topsites"] = false;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should fetch Highlights if TopSites are not shown on NTP", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ feed.store.state.Prefs.values["feeds.topsites"] = false;
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.linksCache.request);
+ assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights);
+ });
+ it("should add hostname and hasImage to each link", async () => {
+ links = [{ url: "https://mozilla.org" }];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].hostname, "mozilla.org");
+ assert.equal(highlights[0].hasImage, true);
+ });
+ it("should add an existing image if it exists to the link without calling fetchImage", async () => {
+ links = [{ url: "https://mozilla.org", image: FAKE_IMAGE }];
+ sinon.spy(feed, "fetchImage");
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].image, FAKE_IMAGE);
+ assert.notCalled(feed.fetchImage);
+ });
+ it("should call fetchImage with the correct arguments for new links", async () => {
+ links = [
+ {
+ url: "https://mozilla.org",
+ preview_image_url: "https://mozilla.org/preview.jog",
+ },
+ ];
+ sinon.spy(feed, "fetchImage");
+
+ await feed.fetchHighlights();
+
+ assert.calledOnce(feed.fetchImage);
+ const [arg] = feed.fetchImage.firstCall.args;
+ assert.propertyVal(arg, "url", links[0].url);
+ assert.propertyVal(arg, "preview_image_url", links[0].preview_image_url);
+ });
+ it("should not include any links already in Top Sites", async () => {
+ links = [
+ { url: "https://mozilla.org" },
+ { url: "http://www.topsite0.com" },
+ { url: "http://www.topsite1.com" },
+ { url: "http://www.topsite2.com" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should include bookmark but not history already in Top Sites", async () => {
+ links = [
+ { url: "http://www.topsite0.com", type: "bookmark" },
+ { url: "http://www.topsite1.com", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should not include history of same hostname as a bookmark", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/history", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 1);
+ assert.equal(highlights[0].url, links[0].url);
+ });
+ it("should take the first history of a hostname", async () => {
+ links = [
+ { url: "https://site.com/first", type: "history" },
+ { url: "https://site.com/second", type: "history" },
+ { url: "https://other", type: "history" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[2].url);
+ });
+ it("should take a bookmark, a pocket, and downloaded item of the same hostname", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ { url: "https://site.com/download", type: "download" },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ 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);
+ });
+ it("should includePocket pocket items when pref is true", async () => {
+ feed.store.state.Prefs.values["section.highlights.includePocket"] = true;
+ sandbox.spy(feed.linksCache, "request");
+ await feed.fetchHighlights();
+
+ assert.propertyVal(
+ feed.linksCache.request.firstCall.args[0],
+ "excludePocket",
+ false
+ );
+ });
+ it("should not includePocket pocket items when pref is false", async () => {
+ sandbox.spy(feed.linksCache, "request");
+ await feed.fetchHighlights();
+
+ assert.propertyVal(
+ feed.linksCache.request.firstCall.args[0],
+ "excludePocket",
+ true
+ );
+ });
+ it("should not include downloads when includeDownloads pref is false", async () => {
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ ];
+
+ // Check that we don't have the downloaded item in highlights
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, links[1].url);
+ });
+ it("should include downloads when includeDownloads pref is true", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ links = [
+ { url: "https://site.com/bookmark", type: "bookmark" },
+ { url: "https://site.com/pocket", type: "pocket" },
+ ];
+
+ // Check that we did get the downloaded item in highlights
+ const highlights = await fetchHighlights();
+ 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.propertyVal(highlights[2], "type", "download");
+ });
+ it("should only take 1 download", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ feed.downloadsManager.getDownloads = () => [
+ { url: "https://site1.com/download" },
+ { url: "https://site2.com/download" },
+ ];
+ links = [{ url: "https://site.com/bookmark", type: "bookmark" }];
+
+ // Check that we did get the most single recent downloaded item in highlights
+ const highlights = await fetchHighlights();
+ assert.equal(highlights.length, 2);
+ assert.equal(highlights[0].url, links[0].url);
+ assert.equal(highlights[1].url, "https://site1.com/download");
+ });
+ it("should sort bookmarks, pocket, and downloads chronologically", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeDownloads"
+ ] = true;
+ feed.downloadsManager.getDownloads = () => [
+ {
+ url: "https://site1.com/download",
+ type: "download",
+ date_added: Date.now(),
+ },
+ ];
+ 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(),
+ },
+ ];
+
+ // Check that the higlights are ordered chronologically by their 'date_added'
+ const highlights = await fetchHighlights();
+ 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
+ });
+ it("should set type to bookmark if there is a bookmarkGuid", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeBookmarks"
+ ] = true;
+ links = [
+ {
+ url: "https://mozilla.org",
+ type: "history",
+ bookmarkGuid: "1234567890",
+ },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.equal(highlights[0].type, "bookmark");
+ });
+ it("should keep history type if there is a bookmarkGuid but don't include bookmarks", async () => {
+ feed.store.state.Prefs.values[
+ "section.highlights.includeBookmarks"
+ ] = false;
+ links = [
+ {
+ url: "https://mozilla.org",
+ type: "history",
+ bookmarkGuid: "1234567890",
+ },
+ ];
+
+ const highlights = await fetchHighlights();
+
+ assert.propertyVal(highlights[0], "type", "history");
+ });
+ it("should filter out adult pages", async () => {
+ filterAdultStub.filter = sinon.stub().returns([]);
+ const highlights = await fetchHighlights();
+
+ // The stub filters out everything
+ assert.calledOnce(filterAdultStub.filter);
+ assert.equal(highlights.length, 0);
+ });
+ it("should not expose internal link properties", async () => {
+ const highlights = await fetchHighlights();
+
+ const internal = Object.keys(highlights[0]).filter(key =>
+ key.startsWith("__")
+ );
+ assert.equal(internal.join(""), "");
+ });
+ it("should broadcast if feed is not initialized", async () => {
+ links = [];
+ await fetchHighlights();
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ true,
+ undefined
+ );
+ });
+ it("should broadcast if options.broadcast is true", async () => {
+ links = [];
+ feed.store.state.Sections[0].initialized = true;
+ await fetchHighlights({ broadcast: true });
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ true,
+ undefined
+ );
+ });
+ it("should not broadcast if options.broadcast is false and initialized is true", async () => {
+ links = [];
+ feed.store.state.Sections[0].initialized = true;
+ await fetchHighlights({ broadcast: false });
+
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithExactly(
+ sectionsManagerStub.updateSection,
+ SECTION_ID,
+ { rows: [] },
+ false,
+ undefined
+ );
+ });
+ });
+ describe("#fetchImage", () => {
+ const FAKE_URL = "https://mozilla.org";
+ const FAKE_IMAGE_URL = "https://mozilla.org/preview.jpg";
+ function fetchImage(page) {
+ return feed.fetchImage(
+ Object.assign({ __sharedCache: { updateLink() {} } }, page)
+ );
+ }
+ it("should capture the image, if available", async () => {
+ await fetchImage({
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ });
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_IMAGE_URL);
+ });
+ it("should fall back to capturing a screenshot", async () => {
+ await fetchImage({ url: FAKE_URL });
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_URL);
+ });
+ it("should call SectionsManager.updateSectionCard with the right arguments", async () => {
+ await fetchImage({
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ });
+
+ assert.calledOnce(sectionsManagerStub.updateSectionCard);
+ assert.calledWith(
+ sectionsManagerStub.updateSectionCard,
+ "highlights",
+ FAKE_URL,
+ { image: FAKE_IMAGE },
+ true
+ );
+ });
+ it("should not update the card with the image", async () => {
+ const card = {
+ preview_image_url: FAKE_IMAGE_URL,
+ url: FAKE_URL,
+ };
+
+ await fetchImage(card);
+
+ assert.notProperty(card, "image");
+ });
+ });
+ describe("#uninit", () => {
+ it("should disable its section", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
+ });
+ it("should remove the expiration filter", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(fakePageThumbs.removeExpirationFilter);
+ });
+ it("should remove the sync and Places observers", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ SYNC_BOOKMARKS_FINISHED_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BOOKMARKS_RESTORE_SUCCESS_EVENT
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BOOKMARKS_RESTORE_FAILED_EVENT
+ );
+ });
+ });
+ describe("#onAction", () => {
+ it("should relay all actions to DownloadsManager.onAction", () => {
+ let action = {
+ type: at.COPY_DOWNLOAD_LINK,
+ data: { url: "foo.png" },
+ _target: {},
+ };
+ feed.onAction(action);
+ assert.calledWith(feed.downloadsManager.onAction, action);
+ });
+ it("should fetch highlights on SYSTEM_TICK", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, {
+ broadcast: false,
+ isStartup: false,
+ });
+ });
+ it("should fetch highlights on PREF_CHANGED for include prefs", async () => {
+ feed.fetchHighlights = sinon.spy();
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "section.highlights.includeBookmarks" },
+ });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should not fetch highlights on PREF_CHANGED for other prefs", async () => {
+ feed.fetchHighlights = sinon.spy();
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "section.topstories.pocketCta" },
+ });
+
+ assert.notCalled(feed.fetchHighlights);
+ });
+ it("should fetch highlights on PLACES_HISTORY_CLEARED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.PLACES_HISTORY_CLEARED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights on DOWNLOAD_CHANGED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.DOWNLOAD_CHANGED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights on PLACES_LINKS_CHANGED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ sandbox.stub(feed.linksCache, "expire");
+
+ feed.onAction({ type: at.PLACES_LINKS_CHANGED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: false });
+ assert.calledOnce(feed.linksCache.expire);
+ });
+ it("should fetch highlights on PLACES_LINK_BLOCKED", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({ type: at.PLACES_LINK_BLOCKED });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: true });
+ });
+ it("should fetch highlights and expire the cache on PLACES_SAVED_TO_POCKET", async () => {
+ await feed.fetchHighlights();
+ feed.fetchHighlights = sinon.spy();
+ sandbox.stub(feed.linksCache, "expire");
+
+ feed.onAction({ type: at.PLACES_SAVED_TO_POCKET });
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWith(feed.fetchHighlights, { broadcast: false });
+ assert.calledOnce(feed.linksCache.expire);
+ });
+ it("should call fetchHighlights with broadcast false on TOP_SITES_UPDATED", () => {
+ sandbox.stub(feed, "fetchHighlights");
+ feed.onAction({ type: at.TOP_SITES_UPDATED });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, {
+ broadcast: false,
+ isStartup: false,
+ });
+ });
+ it("should call fetchHighlights when deleting or archiving from Pocket", async () => {
+ feed.fetchHighlights = sinon.spy();
+ feed.onAction({
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.fetchHighlights);
+ assert.calledWithExactly(feed.fetchHighlights, { broadcast: true });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/LinksCache.test.js b/browser/components/newtab/test/unit/lib/LinksCache.test.js
new file mode 100644
index 0000000000..8a4d33d2f2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/LinksCache.test.js
@@ -0,0 +1,16 @@
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+describe("LinksCache", () => {
+ it("throws when failing request", async () => {
+ const cache = new LinksCache();
+
+ let rejected = false;
+ try {
+ await cache.request();
+ } catch (error) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js
new file mode 100644
index 0000000000..5357290a76
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js
@@ -0,0 +1,336 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs";
+import { _MomentsPageHub } from "lib/MomentsPageHub.jsm";
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+describe("MomentsPageHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let handleMessageRequestStub;
+ let addImpressionStub;
+ let blockMessageByIdStub;
+ let sendTelemetryStub;
+ let getStringPrefStub;
+ let setStringPrefStub;
+ let setIntervalStub;
+ let clearIntervalStub;
+
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _MomentsPageHub();
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ handleMessageRequestStub = sandbox.stub().resolves(messages);
+ addImpressionStub = sandbox.stub();
+ blockMessageByIdStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ setIntervalStub = sandbox.stub();
+ clearIntervalStub = sandbox.stub();
+ sendTelemetryStub = sandbox.stub();
+ globals.set({
+ setInterval: setIntervalStub,
+ clearInterval: clearIntervalStub,
+ Services: {
+ prefs: {
+ getStringPref: getStringPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ telemetry: {
+ recordEvent: () => {},
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ it("should create an instance", async () => {
+ setIntervalStub.returns(42);
+ assert.ok(instance);
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ assert.equal(instance.state._intervalId, 42);
+ });
+
+ it("should init only once", async () => {
+ assert.notCalled(handleMessageRequestStub);
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+
+ instance.uninit();
+
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ });
+
+ it("should uninit the instance", () => {
+ instance.uninit();
+ assert.calledOnce(clearIntervalStub);
+ });
+
+ it("should setInterval for `checkHomepageOverridePref`", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ sandbox.stub(instance, "checkHomepageOverridePref");
+
+ assert.calledOnce(setIntervalStub);
+ assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000);
+
+ assert.notCalled(instance.checkHomepageOverridePref);
+ const [cb] = setIntervalStub.firstCall.args;
+
+ cb();
+
+ assert.calledOnce(instance.checkHomepageOverridePref);
+ });
+
+ describe("#messageRequest", () => {
+ beforeEach(async () => {
+ await instance.init(Promise.resolve(), {
+ handleMessageRequest: handleMessageRequestStub,
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledTwice(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ returnAll: true,
+ });
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ // Reset the call from `instance.init`
+ setStringPrefStub.reset();
+ handleMessageRequestStub.resolves([]);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(setStringPrefStub);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ it("should record Reach event for the Moments page experiment", async () => {
+ const momentsMessages = (await PanelTestProvider.getMessages()).filter(
+ ({ template }) => template === "update_action"
+ );
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ...momentsMessages,
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.calledOnce(instance.executeAction);
+ });
+ it("should not record the Reach event if it's already sent", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: true },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(global.Services.telemetry.recordEvent);
+ });
+ it("should not trigger the action if it's only for the Reach event", async () => {
+ const messages = [
+ {
+ forReachEvent: { sent: false },
+ experimentSlug: "foo",
+ branchSlug: "bar",
+ },
+ ];
+ handleMessageRequestStub.resolves(messages);
+ sandbox.spy(global.Services.telemetry, "recordEvent");
+ sandbox.spy(instance, "executeAction");
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(global.Services.telemetry.recordEvent);
+ assert.notCalled(instance.executeAction);
+ });
+ });
+ describe("executeAction", () => {
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: addImpressionStub,
+ blockMessageById: blockMessageByIdStub,
+ sendTelemetry: sendTelemetryStub,
+ });
+ });
+ it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ sandbox.useFakeTimers();
+ instance.executeAction(msg);
+
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: instance.getExpirationDate(
+ msg.content.action.data.expireDelta
+ ),
+ })
+ );
+ });
+ it("should block after taking the action", async () => {
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, msg.id);
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ instance.executeAction(msg);
+
+ assert.calledOnce(instance.getExpirationDate);
+ assert.calledWithExactly(
+ instance.getExpirationDate,
+ msg.content.action.data.expireDelta
+ );
+ });
+ it("should compute expire based on expireDelta", async () => {
+ sandbox.spy(instance, "getExpirationDate");
+
+ const [msg] = await handleMessageRequestStub();
+ const msgWithExpire = {
+ ...msg,
+ content: {
+ ...msg.content,
+ action: {
+ ...msg.content.action,
+ data: { ...msg.content.action.data, expire: 41 },
+ },
+ },
+ };
+ instance.executeAction(msgWithExpire);
+
+ assert.notCalled(instance.getExpirationDate);
+ assert.calledOnce(setStringPrefStub);
+ assert.calledWithExactly(
+ setStringPrefStub,
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({
+ message_id: msg.id,
+ url: msg.content.action.data.url,
+ expire: 41,
+ })
+ );
+ });
+ it("should send user telemetry", async () => {
+ const [msg] = await handleMessageRequestStub();
+ const sendUserEventTelemetrySpy = sandbox.spy(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ instance.executeAction(msg);
+
+ assert.calledOnce(sendTelemetryStub);
+ assert.calledWithExactly(sendUserEventTelemetrySpy, msg);
+ assert.calledWithExactly(sendTelemetryStub, {
+ type: "MOMENTS_PAGE_TELEMETRY",
+ data: {
+ action: "moments_user_event",
+ bucket_id: "WNP_THANK_YOU",
+ event: "MOMENTS_PAGE_SET",
+ message_id: "WNP_THANK_YOU",
+ },
+ });
+ });
+ });
+ describe("#checkHomepageOverridePref", () => {
+ let messageRequestStub;
+ beforeEach(() => {
+ messageRequestStub = sandbox.stub(instance, "messageRequest");
+ });
+ it("should catch parse errors", () => {
+ getStringPrefStub.returns({});
+
+ instance.checkHomepageOverridePref();
+
+ assert.calledOnce(messageRequestStub);
+ assert.calledWithExactly(messageRequestStub, {
+ template: "update_action",
+ triggerId: "momentsUpdate",
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/NewTabInit.test.js b/browser/components/newtab/test/unit/lib/NewTabInit.test.js
new file mode 100644
index 0000000000..834409669f
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/NewTabInit.test.js
@@ -0,0 +1,81 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { NewTabInit } from "lib/NewTabInit.jsm";
+
+describe("NewTabInit", () => {
+ let instance;
+ let store;
+ let STATE;
+ const requestFromTab = portID =>
+ instance.onAction(
+ ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }, portID)
+ );
+ beforeEach(() => {
+ STATE = {};
+ store = { getState: sinon.stub().returns(STATE), dispatch: sinon.stub() };
+ instance = new NewTabInit();
+ instance.store = store;
+ });
+ it("should reply with a copy of the state immediately", () => {
+ requestFromTab(123);
+
+ const resp = ac.AlsoToOneContent(
+ { type: at.NEW_TAB_INITIAL_STATE, data: STATE },
+ 123
+ );
+ assert.calledWith(store.dispatch, resp);
+ });
+ describe("early / simulated new tabs", () => {
+ const simulateTabInit = portID =>
+ instance.onAction({
+ type: at.NEW_TAB_INIT,
+ data: { portID, simulated: true },
+ });
+ beforeEach(() => {
+ simulateTabInit("foo");
+ });
+ it("should dispatch if not replied yet", () => {
+ requestFromTab("foo");
+
+ assert.calledWith(
+ store.dispatch,
+ ac.AlsoToOneContent(
+ { type: at.NEW_TAB_INITIAL_STATE, data: STATE },
+ "foo"
+ )
+ );
+ });
+ it("should dispatch once for multiple requests", () => {
+ requestFromTab("foo");
+ requestFromTab("foo");
+ requestFromTab("foo");
+
+ assert.calledOnce(store.dispatch);
+ });
+ describe("multiple tabs", () => {
+ beforeEach(() => {
+ simulateTabInit("bar");
+ });
+ it("should dispatch once to each tab", () => {
+ requestFromTab("foo");
+ requestFromTab("bar");
+ assert.calledTwice(store.dispatch);
+ requestFromTab("foo");
+ requestFromTab("bar");
+
+ assert.calledTwice(store.dispatch);
+ });
+ it("should clean up when tabs close", () => {
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 2);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 1);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 1);
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "bar"));
+ assert.propertyVal(instance._repliedEarlyTabs, "size", 0);
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersistentCache.test.js b/browser/components/newtab/test/unit/lib/PersistentCache.test.js
new file mode 100644
index 0000000000..e645b8d398
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersistentCache.test.js
@@ -0,0 +1,142 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersistentCache } from "lib/PersistentCache.sys.mjs";
+
+describe("PersistentCache", () => {
+ let fakeIOUtils;
+ let fakePathUtils;
+ let cache;
+ let filename = "cache.json";
+ let consoleErrorStub;
+ let globals;
+ let sandbox;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ fakeIOUtils = {
+ writeJSON: sinon.stub().resolves(0),
+ readJSON: sinon.stub().resolves({}),
+ };
+ fakePathUtils = {
+ join: sinon.stub().returns(filename),
+ localProfileDir: "/",
+ };
+ consoleErrorStub = sandbox.stub();
+ globals.set("console", { error: consoleErrorStub });
+ globals.set("IOUtils", fakeIOUtils);
+ globals.set("PathUtils", fakePathUtils);
+
+ cache = new PersistentCache(filename);
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ describe("#get", () => {
+ it("tries to read the file", async () => {
+ await cache.get("foo");
+ assert.calledOnce(fakeIOUtils.readJSON);
+ });
+ it("doesnt try to read the file if it was already loaded", async () => {
+ await cache._load();
+ fakeIOUtils.readJSON.resetHistory();
+ await cache.get("foo");
+ assert.notCalled(fakeIOUtils.readJSON);
+ });
+ it("should catch and report errors", async () => {
+ fakeIOUtils.readJSON.rejects(new SyntaxError("Failed to parse JSON"));
+ await cache._load();
+ assert.calledOnce(consoleErrorStub);
+
+ cache._cache = undefined;
+ consoleErrorStub.resetHistory();
+
+ fakeIOUtils.readJSON.rejects(
+ new DOMException("IOUtils shutting down", "AbortError")
+ );
+ await cache._load();
+ assert.calledOnce(consoleErrorStub);
+
+ cache._cache = undefined;
+ consoleErrorStub.resetHistory();
+
+ fakeIOUtils.readJSON.rejects(
+ new DOMException("File not found", "NotFoundError")
+ );
+ await cache._load();
+ assert.notCalled(consoleErrorStub);
+ });
+ it("returns data for a given cache key", async () => {
+ fakeIOUtils.readJSON.resolves({ foo: "bar" });
+ let value = await cache.get("foo");
+ assert.equal(value, "bar");
+ });
+ it("returns undefined for a cache key that doesn't exist", async () => {
+ let value = await cache.get("baz");
+ assert.equal(value, undefined);
+ });
+ it("returns all the data if no cache key is specified", async () => {
+ fakeIOUtils.readJSON.resolves({ foo: "bar" });
+ let value = await cache.get();
+ assert.deepEqual(value, { foo: "bar" });
+ });
+ });
+
+ describe("#set", () => {
+ it("tries to read the file on the first set", async () => {
+ await cache.set("foo", { x: 42 });
+ assert.calledOnce(fakeIOUtils.readJSON);
+ });
+ it("doesnt try to read the file if it was already loaded", async () => {
+ cache = new PersistentCache(filename, true);
+ await cache._load();
+ fakeIOUtils.readJSON.resetHistory();
+ await cache.set("foo", { x: 42 });
+ assert.notCalled(fakeIOUtils.readJSON);
+ });
+ it("sets a string value", async () => {
+ const key = "testkey";
+ const value = "testvalue";
+ await cache.set(key, value);
+ const cachedValue = await cache.get(key);
+ assert.equal(cachedValue, value);
+ });
+ it("sets an object value", async () => {
+ const key = "testkey";
+ const value = { x: 1, y: 2, z: 3 };
+ await cache.set(key, value);
+ const cachedValue = await cache.get(key);
+ assert.deepEqual(cachedValue, value);
+ });
+ it("writes the data to file", async () => {
+ const key = "testkey";
+ const value = { x: 1, y: 2, z: 3 };
+
+ await cache.set(key, value);
+ assert.calledOnce(fakeIOUtils.writeJSON);
+ assert.calledWith(
+ fakeIOUtils.writeJSON,
+ filename,
+ { [[key]]: value },
+ { tmpPath: `${filename}.tmp` }
+ );
+ });
+ it("throws when failing to get file path", async () => {
+ Object.defineProperty(fakePathUtils, "localProfileDir", {
+ get() {
+ throw new Error();
+ },
+ });
+
+ let rejected = false;
+ try {
+ await cache.set("key", "val");
+ } catch (error) {
+ rejected = true;
+ }
+
+ assert(rejected);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js
new file mode 100644
index 0000000000..0751cafb4f
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js
@@ -0,0 +1,95 @@
+import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("Naive Bayes Tagger", () => {
+ describe("#tag", () => {
+ let model = {
+ model_type: "nb",
+ positive_class_label: "military",
+ positive_class_id: 0,
+ positive_class_threshold_log_prob: -0.5108256237659907,
+ classes: [
+ {
+ log_prior: -0.6881346387364013,
+ feature_log_probs: [
+ -6.2149425847276, -6.829869141665873, -7.124856122235796,
+ -7.116661287797188, -6.694751331313906, -7.11798266787003,
+ -6.5094904366004185, -7.1639509366900604, -7.218981434452414,
+ -6.854842907887801, -7.080328841624584,
+ ],
+ },
+ {
+ log_prior: -0.6981849745899025,
+ feature_log_probs: [
+ -7.0575941199203465, -6.632333513597953, -7.382756370680115,
+ -7.1160793981275905, -8.467120918791892, -8.369201274990882,
+ -8.518506617006922, -7.015756380369387, -7.739036845511857,
+ -9.748294397894645, -3.9353548206941955,
+ ],
+ },
+ ],
+ vocab_idfs: {
+ deal: [0, 5.5058519847862275],
+ easy: [1, 5.5058519847862275],
+ tanks: [2, 5.601162164590552],
+ sites: [3, 5.957837108529285],
+ care: [4, 5.957837108529285],
+ needs: [5, 5.824305715904762],
+ finally: [6, 5.706522680248379],
+ super: [7, 5.264689927969339],
+ heard: [8, 5.5058519847862275],
+ reached: [9, 5.957837108529285],
+ words: [10, 5.070533913528382],
+ },
+ };
+ let instance = new NaiveBayesTextTagger(model, toksToTfIdfVector);
+
+ let testCases = [
+ {
+ input: "Finally! Super easy care for your tanks!",
+ expected: {
+ label: "military",
+ logProb: -0.16299510296630082,
+ confident: true,
+ },
+ },
+ {
+ input: "heard",
+ expected: {
+ label: "military",
+ logProb: -0.4628170738373294,
+ confident: false,
+ },
+ },
+ {
+ input: "words",
+ expected: {
+ label: null,
+ logProb: -0.04258339303757985,
+ confident: false,
+ },
+ },
+ ];
+
+ let checkTag = tc => {
+ let actual = instance.tagTokens(tokenize(tc.input));
+ it(`should tag ${tc.input} with ${tc.expected.label}`, () => {
+ assert.equal(tc.expected.label, actual.label);
+ });
+ it(`should give ${tc.input} the correct probability`, () => {
+ let delta = Math.abs(tc.expected.logProb - actual.logProb);
+ assert.isTrue(delta <= EPSILON);
+ });
+ };
+
+ // RELEASE THE TESTS!
+ for (let tc of testCases) {
+ checkTag(tc);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js
new file mode 100644
index 0000000000..fb3abb1367
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js
@@ -0,0 +1,479 @@
+import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("NMF Tagger", () => {
+ describe("#tag", () => {
+ // The numbers in this model were pulled from existing trained model.
+ let model = {
+ document_topic: {
+ environment: [
+ 0.05313956429537541, 0.07314019377743895, 0.03247190024863182,
+ 0.016189529772591395, 0.003812317145412572, 0.03863075834647775,
+ 0.007495425135831521, 0.005100298003919777, 0.005245622179405364,
+ 0.036196010766427554, 0.02189970342121833, 0.03514130992119014,
+ 0.001248114096050196, 0.0030908722594824665, 0.0023874256586350626,
+ 0.008533674814792993, 0.0009424690250135675, 0.01603124888144218,
+ 0.00752822798092765, 0.0039046678154748796, 0.03521776907836766,
+ 0.00614546613169027, 0.0008272200196643818, 0.01405638079154697,
+ 0.001990670259485496, 0.002803666919676377, 0.013841677883061631,
+ 0.004093362693745272, 0.009310678536276432, 0.006158920150866703,
+ 0.006821027337091937, 0.002712031105462971, 0.009093298611644996,
+ 0.014642160500331744, 0.0067239941045715386, 0.007150418784462898,
+ 0.0064652818600521265, 0.0006735690394489199, 0.02063188588742841,
+ 0.003213083349614106, 0.0031998068360970093, 0.00264520606931871,
+ 0.008854824468146531, 0.0024170562884908786, 0.0013705390639746128,
+ 0.0030575940757273288, 0.010417378215688392, 0.002356164040132228,
+ 0.0026710154645455007, 0.0007295327370144145, 0.0585307418954327,
+ 0.0037987763460599574, 0.003199095437138493, 0.004368800434950577,
+ 0.005087168372751965, 0.0011100904433965942, 0.01700096791869979,
+ 0.01929226435023826, 0.010536397909643058, 0.001734999985783697,
+ 0.003852807194017686, 0.007916805773686475, 0.028375307444815964,
+ 0.0012422599635274355, 0.0009298594944844238, 0.02095410849846837,
+ 0.0017269844428419192, 0.002152880993141985, 0.0030226616228192387,
+ 0.004804812297400959, 0.0012383636748462198, 0.006991278216261148,
+ 0.0013747035300597538, 0.002041541234639563, 0.012076270996247411,
+ 0.006643837514421182, 0.003974012776560734, 0.015794539051705442,
+ 0.007601190171659186, 0.016474925942594837, 0.002729423078513777,
+ 0.007635146179880609, 0.013457547041824648, 0.0007592338429017099,
+ 0.002947096673767141, 0.006371935735541048, 0.003356178481568716,
+ 0.00451933490245723, 0.0019006306992329104, 0.013048046603391707,
+ 0.023541628496101297, 0.027659066125377194, 0.002312727786055524,
+ 0.0014189157259186062, 0.01963766030236683, 0.0026014761547439634,
+ 0.002333697870992923, 0.003401734295211338, 0.002522073778255918,
+ 0.0015769783084977752,
+ ],
+ space: [
+ 0.045976774394786174, 0.04386532305052323, 0.03346748817597193,
+ 0.008498345884036708, 0.005802390890667938, 0.0017673346473868704,
+ 0.00468037374691276, 0.0036807899985757367, 0.0034951488381868424,
+ 0.015073756869093244, 0.006784747891785806, 0.03069702365741547,
+ 0.004945214461908244, 0.002527030239506901, 0.0012201743197690308,
+ 0.010191409658936534, 0.0013882500616525532, 0.014559679471816162,
+ 0.005308140956577744, 0.002067005832569046, 0.006092496689239475,
+ 0.0029308442356851265, 0.0006407392160713908, 0.01669972147417425,
+ 0.0018920321527190246, 0.002436089537269062, 0.05542174181989591,
+ 0.006448761215865303, 0.012804154851567844, 0.014553974971946687,
+ 0.004927456148063145, 0.006085620881900181, 0.011626122370522652,
+ 0.002994267915422563, 0.0038291031528493898, 0.006987917175322377,
+ 0.00719289436611732, 0.0008398926158042337, 0.019068654506361523,
+ 0.004453895285397824, 0.00401164781243836, 0.0031309255764704544,
+ 0.013210118660087334, 0.0015542151889036313, 0.0013951089590218057,
+ 0.002790924761398501, 0.008739250167366135, 0.0027834569638271025,
+ 0.09198161284531065, 0.0019488047187835441, 0.001739971582806101,
+ 0.005113637251322287, 0.12140493794373561, 0.005535368890812829,
+ 0.004198222617607059, 0.0010670629105233682, 0.005298717616708989,
+ 0.0048291586850982855, 0.005140125537186181, 0.0011663683373124493,
+ 0.0024499638218810943, 0.012532772497286819, 0.0015564613278042862,
+ 0.0012252899339204029, 0.0005095187051357676, 0.0035442657060978655,
+ 0.014030578705118285, 0.0017653534252553718, 0.004026729875153457,
+ 0.004002067082856801, 0.00809773970333208, 0.017160384509220625,
+ 0.002981945110677171, 0.0018338446554387704, 0.0031886913904107484,
+ 0.004654622711785796, 0.0053886727821435415, 0.009023511029300392,
+ 0.005246967669202147, 0.022806469628558337, 0.0035142224878495355,
+ 0.006793295047927272, 0.017396620747821886, 0.000922278971300957,
+ 0.001695889413253992, 0.007015197552957029, 0.003908581792868586,
+ 0.010136260994789877, 0.0032880552208979508, 0.0039712539426523625,
+ 0.009672046620728448, 0.007290428293346, 0.0017814796852793386,
+ 0.0005388988974780036, 0.013936726486762537, 0.003427738251710856,
+ 0.002206664729558829, 0.05072392472622557, 0.004424158921356747,
+ 0.0003680061331891622,
+ ],
+ biology: [
+ 0.054433533850037796, 0.039689474154513994, 0.027661000660240884,
+ 0.021655563357213067, 0.007862624595639219, 0.006280655377019006,
+ 0.013407714984668861, 0.004038592819712647, 0.009652765217013826,
+ 0.0011353987945632667, 0.00925298156804724, 0.004870163054917538,
+ 0.04911204317171355, 0.006921538451191124, 0.004003624507234068,
+ 0.016600722822360296, 0.002179735905957767, 0.010801493818182368,
+ 0.00918922860910538, 0.022115576350545514, 0.0027720850555002148,
+ 0.003290714340925284, 0.0006359939927595049, 0.020564054347194806,
+ 0.019590591011010666, 0.0029008397180383077, 0.030414664509122412,
+ 0.002864704837438281, 0.030933936414333993, 0.00222576969791357,
+ 0.007077232390623289, 0.005876547862506722, 0.016917705934608753,
+ 0.016466207380001166, 0.006648808144677746, 0.017876914915160164,
+ 0.008216930648675583, 0.0026813239798232098, 0.012171904585413245,
+ 0.012319763594831614, 0.003909608203628946, 0.003205613981613637,
+ 0.027729523430009183, 0.0019938396819227074, 0.002752482544417343,
+ 0.0016746657427111145, 0.019564250521109314, 0.027250898086440583,
+ 0.000954251437229793, 0.0020431321836649734, 0.0014636128217840221,
+ 0.006821766389705783, 0.003272989792090916, 0.011086677363737012,
+ 0.0044279892365732595, 0.0029213721398486203, 0.013081117655947345,
+ 0.012102962176204816, 0.0029165848047082825, 0.002363073972325097,
+ 0.0028567640089643695, 0.013692951578614878, 0.0013189478722657382,
+ 0.0030662419379415885, 0.001688218039583749, 0.0007806438728749603,
+ 0.025458033834110355, 0.009584308792578437, 0.0033243840056188263,
+ 0.0068361098488461045, 0.005178034666939756, 0.006831575853694424,
+ 0.010170774789130092, 0.004639315532453418, 0.00655511046953238,
+ 0.005661100806175219, 0.006238755352678196, 0.023282136482285103,
+ 0.007790828526461584, 0.011840304456780202, 0.0021953903460442225,
+ 0.011205225479328193, 0.01665869590158306, 0.0009257333679666402,
+ 0.0032380769616003604, 0.007379754534437712, 0.01804771060116468,
+ 0.02540492978451049, 0.0027900782593570507, 0.0029721824342474694,
+ 0.005666888959879564, 0.003629523931553047, 0.0017838703067849428,
+ 0.004996486217852931, 0.006086510142627035, 0.0023570031997685236,
+ 0.002718397814380002, 0.003908858478916721, 0.02080129902865465,
+ 0.005591305783253238,
+ ],
+ },
+ topic_word: [
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.003173633134427233, 0.0, 0.0,
+ 0.0019409914586816176, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 5.135548639746091e-5, 0.0, 0.0, 0.0,
+ 0.00015384770766669982,
+ ],
+ [
+ 0.0, 0.0, 0.0005001441880557176, 0.0, 0.0, 0.0012069823147301646,
+ 0.02401141538644239, 8.831990149479376e-5, 0.001813504147854849, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003577161362340021, 0.0005744157863408606,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.002662246533243532, 0.0, 0.0,
+ 0.0008394369973758684, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 4.768637450522633e-5, 0.0, 0.0, 0.0, 0.0, 0.0010421065429755969,
+ 0.0, 0.0, 2.3210938729937306e-5,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006034363278588148,
+ 0.001690622339085902, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.004257728522853072, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0007238839225620208, 0.0, 0.0, 0.0, 0.0, 0.0009507496006759083,
+ 0.0012635532859311572, 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.2699264109324263e-5,
+ 0.00032868342552128994, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0011157667743487598, 0.001278875789622101,
+ 9.011724853181247e-6, 0.0, 3.22069766200917e-5, 0.004124963644732435,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00011961487736485771],
+ [0.0, 0.0, 0.0, 5.734703813314615e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 4.0340264022466226e-5, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.00039701897786057513, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.19635202968946042, 0.0, 0.0008873887898279083, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 1.552973162326247e-5, 0.0,
+ 0.002284331845105356, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.005561738919282601, 0.0, 0.0, 0.0, 0.010700323065082812,
+ 0.0, 0.0005795117202094265, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0005085828329663487, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.029261090049475084, 0.0020864946050332834,
+ 0.0018513709831557076, 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008328286790309667, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013227647245223537, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0024010554774254685, 5.357245317969706e-5, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014484032312145462, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0012081428144960678, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.000616488580813398, 0.0, 0.0, 0.0017954524796671627, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0006660554263924299, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011891151421092303, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0024885434472066534, 0.0,
+ 0.0010165824086743897, 0.0, 0.0,
+ ],
+ [
+ 0.0, 5.692292246819767e-5, 0.0, 0.0, 0.001006289633741549, 0.0, 0.0,
+ 0.001897882990870404, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00010646854330751878, 0.0,
+ 0.0013480243353754932, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0002608785715957589, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0010620422134845085, 0.0, 0.0,
+ 0.0002032215308376943, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008928062238389307, 0.0, 0.0,
+ 5.727265080002417e-5, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.06061253593083364, 0.0, 0.02739898181912798, 0.0, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014338134220455178, 0.0,
+ 0.0011276871850520397, 0.002840121913315777,
+ ],
+ [0.0008014293374641945, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.000345858724152025, 0.013078498367906305, 0.0,
+ 0.002815596608197659, 0.0, 0.0, 0.0030778986683343023, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0010177321509216356, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.00015333347872060042, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0009655934464519347, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008542046515290346, 0.0, 0.0,
+ 0.00016472517230317488, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0007759590139787148,
+ 0.0037535348789227703, 0.0007205740927611773,
+ ],
+ [
+ 0.0, 0.0, 0.0010313963595627862, 0.0, 0.0, 0.0, 0.0, 0.0,
+ 0.0069665132800572115, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006880323929924655, 9.207429290830475e-5,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0008404475484102756, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.00016603822882009137, 0.0, 0.0, 0.0,
+ 0.0004386724451378034,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.003971386830918022, 0.0, 0.0, 0.0, 0.0],
+ [0.000983926199078037, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.001299108775819868, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.16326515307916822, 0.0, 0.0, 0.0, 0.0, 0.0028677496385613155,
+ 0.023677620702293598, 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 5.737710913345495e-6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0002081792662367579, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+ 0.0002840163488982256,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0005021534925351664, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.001057424953719077, 0.0,
+ 0.003578658690485632, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.00022950619982206556,
+ 0.0018791783657735252, 0.0008530683004027156, 4.5513911743540586e-5,
+ 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0045523319463242765, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0006160628426134845, 0.0, 0.0023393152617350653,
+ 0.0, 0.0, 0.0012979890699731222,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.003391399407584813, 0.0, 0.0, 0.000719659722017165, 0.0,
+ 0.004722518573572638, 0.002758841738663124, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.002127862313876461, 0.0, 0.005031998155190167,
+ 0.0, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.00055401373160389, 0.0, 0.0, 0.000333325450244618,
+ 0.0017824446558959168, 0.0011398506826041158, 0.0,
+ 0.0006366915431430632,
+ ],
+ [
+ 0.0, 0.21687336139378274, 0.0, 0.0, 0.0, 0.0030345303266644387, 0.0,
+ 0.0, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0012637173523723526, 0.0,
+ 0.0010158476831041915, 0.0035425832276585615, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0015451984659512325, 0.019909953764629045,
+ 0.0013484737840911303, 0.0033472098053086113, 0.0016951819626954759,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00015923419851654453, 0.0,
+ 0.0024056492047359367,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.01305313280419075,
+ 0.00014197157780982973, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.000746430999979358, 0.0,
+ 0.0010041202546700189, 0.004557016648181857, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00021372865758801545,
+ 0.00025925151316940747, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001658746582791234, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.00973640859923001, 0.0012404719999980969,
+ 0.0006365355864806626, 0.0008291013715577852, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.001473459191608214, 0.0, 0.0,
+ 0.0009195459918865811, 0.002012929485852207,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0005850456523130979, 0.0,
+ 0.00014396718214395852, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011858302272740567, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0046803403116507545, 0.002083219444498354, 0.0,
+ 0.0, 0.0, 0.006104495765365948,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.005456944646675863, 0.0,
+ 0.00011428354610339084, 0.0, 0.0,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013384597578988894, 0.0, 0.0, 0.0, 0.0],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0018450592044551373, 0.0,
+ 0.005182965872305058, 0.0, 0.0,
+ ],
+ [
+ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003041074021307749, 0.0,
+ 0.0020827735275448823, 0.0, 0.0008494429669380388,
+ ],
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ ],
+ vocab_idfs: {
+ blood: [0, 5.0948820521571045],
+ earth: [1, 4.2248041634380815],
+ rocket: [2, 5.666668375712782],
+ brain: [3, 4.616846251214104],
+ mars: [4, 6.226284163648205],
+ nothing: [5, 5.270772718620769],
+ nada: [6, 4.815297189937943],
+ star: [7, 6.38880309314598],
+ zilch: [8, 5.889811927026992],
+ soil: [9, 7.14257489552236],
+ },
+ };
+
+ let instance = new NmfTextTagger(model, toksToTfIdfVector);
+
+ let testCases = [
+ {
+ input: "blood is in the brain",
+ expected: {
+ environment: 0.00037336337061919943,
+ space: 0.0003307690554984028,
+ biology: 0.0026549079818439627,
+ },
+ },
+
+ {
+ input: "rocket to the star",
+ expected: {
+ environment: 0.0002855180592590448,
+ space: 0.004006242743506598,
+ biology: 0.0003094182371360131,
+ },
+ },
+ {
+ input: "rocket to the star mars",
+ expected: {
+ environment: 0.0004180326651780644,
+ space: 0.003844259295376754,
+ biology: 0.0003135623817729136,
+ },
+ },
+ {
+ input: "rocket rocket rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ {
+ input: "nothing nada rocket",
+ expected: {
+ environment: 0.0008597524218029812,
+ space: 0.0035401031629944506,
+ biology: 0.000950627767326667,
+ },
+ },
+ {
+ input: "rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ {
+ input: "this sentence is out of vocabulary",
+ expected: {
+ environment: 0.0,
+ space: 0.0,
+ biology: 0.0,
+ },
+ },
+ {
+ input: "this sentence is out of vocabulary except for rocket",
+ expected: {
+ environment: 0.00033052002469507015,
+ space: 0.007519787053895712,
+ biology: 0.00031862864995569246,
+ },
+ },
+ ];
+
+ let checkTag = tc => {
+ let actual = instance.tagTokens(tokenize(tc.input));
+ it(`should score ${tc.input} correctly`, () => {
+ Object.keys(actual).forEach(tag => {
+ let delta = Math.abs(tc.expected[tag] - actual[tag]);
+ assert.isTrue(delta <= EPSILON);
+ });
+ });
+ };
+
+ // RELEASE THE TESTS!
+ for (let tc of testCases) {
+ checkTag(tc);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js
new file mode 100644
index 0000000000..833a9d5a7c
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js
@@ -0,0 +1,356 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+describe("Personality Provider", () => {
+ let instance;
+ let RemoteSettingsStub;
+ let RemoteSettingsOnStub;
+ let RemoteSettingsOffStub;
+ let RemoteSettingsGetStub;
+ let sandbox;
+ let globals;
+ let baseURLStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+
+ RemoteSettingsOnStub = sandbox.stub().returns();
+ RemoteSettingsOffStub = sandbox.stub().returns();
+ RemoteSettingsGetStub = sandbox.stub().returns([]);
+
+ RemoteSettingsStub = name => ({
+ get: RemoteSettingsGetStub,
+ on: RemoteSettingsOnStub,
+ off: RemoteSettingsOffStub,
+ });
+
+ sinon.spy(global, "BasePromiseWorker");
+ sinon.spy(global.BasePromiseWorker.prototype, "post");
+
+ baseURLStub = "https://baseattachmentsurl";
+ global.fetch = async server => ({
+ ok: true,
+ json: async () => {
+ if (server === "bogus://foo/") {
+ return { capabilities: { attachments: { base_url: baseURLStub } } };
+ }
+ return {};
+ },
+ });
+ globals.set("RemoteSettings", RemoteSettingsStub);
+
+ instance = new PersonalityProvider();
+ instance.interestConfig = {
+ history_item_builder: "history_item_builder",
+ history_required_fields: ["a", "b", "c"],
+ interest_finalizer: "interest_finalizer",
+ item_to_rank_builder: "item_to_rank_builder",
+ item_ranker: "item_ranker",
+ interest_combiner: "interest_combiner",
+ };
+ });
+ afterEach(() => {
+ sinon.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#personalityProviderWorker", () => {
+ it("should create a new promise worker on first call", async () => {
+ const { personalityProviderWorker } = instance;
+ assert.calledOnce(global.BasePromiseWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ it("should cache _personalityProviderWorker on first call", async () => {
+ instance._personalityProviderWorker = null;
+ const { personalityProviderWorker } = instance;
+ assert.isDefined(instance._personalityProviderWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ it("should use old promise worker on second call", async () => {
+ let { personalityProviderWorker } = instance;
+ personalityProviderWorker = instance.personalityProviderWorker;
+ assert.calledOnce(global.BasePromiseWorker);
+ assert.isDefined(personalityProviderWorker);
+ });
+ });
+ describe("#_getBaseAttachmentsURL", () => {
+ it("should return a fresh value", async () => {
+ await instance._getBaseAttachmentsURL();
+ assert.equal(instance._baseAttachmentsURL, baseURLStub);
+ });
+ it("should return a cached value", async () => {
+ const cachedURL = "cached";
+ instance._baseAttachmentsURL = cachedURL;
+ await instance._getBaseAttachmentsURL();
+ assert.equal(instance._baseAttachmentsURL, cachedURL);
+ });
+ });
+ describe("#setup", () => {
+ it("should setup two sync attachments", () => {
+ sinon.spy(instance, "setupSyncAttachment");
+ instance.setup();
+ assert.calledTwice(instance.setupSyncAttachment);
+ });
+ });
+ describe("#teardown", () => {
+ it("should teardown two sync attachments", () => {
+ sinon.spy(instance, "teardownSyncAttachment");
+ instance.teardown();
+ assert.calledTwice(instance.teardownSyncAttachment);
+ });
+ it("should terminate worker", () => {
+ const terminateStub = sandbox.stub().returns();
+ instance._personalityProviderWorker = {
+ terminate: terminateStub,
+ };
+ instance.teardown();
+ assert.calledOnce(terminateStub);
+ });
+ });
+ describe("#setupSyncAttachment", () => {
+ it("should call remote settings on twice for setupSyncAttachment", () => {
+ assert.calledTwice(RemoteSettingsOnStub);
+ });
+ });
+ describe("#teardownSyncAttachment", () => {
+ it("should call remote settings off for teardownSyncAttachment", () => {
+ instance.teardownSyncAttachment();
+ assert.calledOnce(RemoteSettingsOffStub);
+ });
+ });
+ describe("#onSync", () => {
+ it("should call worker onSync", () => {
+ instance.onSync();
+ assert.calledWith(global.BasePromiseWorker.prototype.post, "onSync");
+ });
+ });
+ describe("#getAttachment", () => {
+ it("should call worker onSync", () => {
+ instance.getAttachment();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "getAttachment"
+ );
+ });
+ });
+ describe("#getRecipe", () => {
+ it("should call worker getRecipe and remote settings get", async () => {
+ RemoteSettingsGetStub = sandbox.stub().returns([
+ {
+ key: 1,
+ },
+ ]);
+ sinon.spy(instance, "getAttachment");
+ RemoteSettingsStub = name => ({
+ get: RemoteSettingsGetStub,
+ on: RemoteSettingsOnStub,
+ off: RemoteSettingsOffStub,
+ });
+ globals.set("RemoteSettings", RemoteSettingsStub);
+
+ const result = await instance.getRecipe();
+ assert.calledOnce(RemoteSettingsGetStub);
+ assert.calledOnce(instance.getAttachment);
+ assert.equal(result.recordKey, 1);
+ });
+ });
+ describe("#fetchHistory", () => {
+ it("should return a history object for fetchHistory", async () => {
+ const history = await instance.fetchHistory(["requiredColumn"], 1, 1);
+ assert.equal(
+ history.sql,
+ `SELECT url, title, visit_count, frecency, last_visit_date, description\n FROM moz_places\n WHERE last_visit_date >= 1000000\n AND last_visit_date < 1000000 AND IFNULL(requiredColumn, '') <> '' LIMIT 30000`
+ );
+ assert.equal(history.options.columns.length, 1);
+ assert.equal(Object.keys(history.options.params).length, 0);
+ });
+ });
+ describe("#getHistory", () => {
+ it("should return an empty array", async () => {
+ instance.interestConfig = {
+ history_required_fields: [],
+ };
+ const result = await instance.getHistory();
+ assert.equal(result.length, 0);
+ });
+ it("should call fetchHistory", async () => {
+ sinon.spy(instance, "fetchHistory");
+ await instance.getHistory();
+ });
+ });
+ describe("#setBaseAttachmentsURL", () => {
+ it("should call worker setBaseAttachmentsURL", async () => {
+ await instance.setBaseAttachmentsURL();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setBaseAttachmentsURL"
+ );
+ });
+ });
+ describe("#setInterestConfig", () => {
+ it("should call worker setInterestConfig", async () => {
+ await instance.setInterestConfig();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setInterestConfig"
+ );
+ });
+ });
+ describe("#setInterestVector", () => {
+ it("should call worker setInterestVector", async () => {
+ await instance.setInterestVector();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "setInterestVector"
+ );
+ });
+ });
+ describe("#fetchModels", () => {
+ it("should call worker fetchModels and remote settings get", async () => {
+ await instance.fetchModels();
+ assert.calledOnce(RemoteSettingsGetStub);
+ assert.calledWith(global.BasePromiseWorker.prototype.post, "fetchModels");
+ });
+ });
+ describe("#generateTaggers", () => {
+ it("should call worker generateTaggers", async () => {
+ await instance.generateTaggers();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "generateTaggers"
+ );
+ });
+ });
+ describe("#generateRecipeExecutor", () => {
+ it("should call worker generateRecipeExecutor", async () => {
+ await instance.generateRecipeExecutor();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "generateRecipeExecutor"
+ );
+ });
+ });
+ describe("#createInterestVector", () => {
+ it("should call worker createInterestVector", async () => {
+ await instance.createInterestVector();
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "createInterestVector"
+ );
+ });
+ });
+ describe("#init", () => {
+ it("should return early if setInterestConfig fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ instance.interestConfig = null;
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should return early if fetchModels fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: false,
+ });
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should return early if createInterestVector fails", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: false,
+ });
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.notCalled(callback);
+ });
+ it("should call callback on successful init", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "setInterestVector").resolves();
+ const callback = globals.sandbox.stub();
+ await instance.init(callback);
+ assert.calledOnce(callback);
+ assert.isTrue(instance.initialized);
+ });
+ it("should do generic init stuff when calling init with no cache", async () => {
+ sandbox.stub(instance, "setBaseAttachmentsURL").returns();
+ sandbox.stub(instance, "setInterestConfig").returns();
+ sandbox.stub(instance, "fetchModels").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "generateRecipeExecutor").resolves({
+ ok: true,
+ });
+ sandbox.stub(instance, "createInterestVector").resolves({
+ ok: true,
+ interestVector: "interestVector",
+ });
+ sandbox.stub(instance, "setInterestVector").resolves();
+ await instance.init();
+ assert.calledOnce(instance.setBaseAttachmentsURL);
+ assert.calledOnce(instance.setInterestConfig);
+ assert.calledOnce(instance.fetchModels);
+ assert.calledOnce(instance.generateRecipeExecutor);
+ assert.calledOnce(instance.createInterestVector);
+ assert.calledOnce(instance.setInterestVector);
+ });
+ });
+ describe("#calculateItemRelevanceScore", () => {
+ it("should return score for uninitialized provider", async () => {
+ instance.initialized = false;
+ assert.equal(
+ await instance.calculateItemRelevanceScore({ item_score: 2 }),
+ 2
+ );
+ });
+ it("should return score for initialized provider", async () => {
+ instance.initialized = true;
+
+ instance._personalityProviderWorker = {
+ post: (postName, [item]) => ({
+ rankingVector: { score: item.item_score },
+ }),
+ };
+
+ assert.equal(
+ await instance.calculateItemRelevanceScore({ item_score: 2 }),
+ 2
+ );
+ });
+ it("should post calculateItemRelevanceScore to PersonalityProviderWorker", async () => {
+ instance.initialized = true;
+ await instance.calculateItemRelevanceScore({ item_score: 2 });
+ assert.calledWith(
+ global.BasePromiseWorker.prototype.post,
+ "calculateItemRelevanceScore"
+ );
+ });
+ });
+ describe("#getScores", () => {
+ it("should return correct data for getScores", () => {
+ const scores = instance.getScores();
+ assert.isDefined(scores.interestConfig);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js
new file mode 100644
index 0000000000..6dd483ae70
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js
@@ -0,0 +1,456 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { PersonalityProviderWorker } from "lib/PersonalityProvider/PersonalityProviderWorkerClass.jsm";
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm";
+import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm";
+import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm";
+
+describe("Personality Provider Worker Class", () => {
+ let instance;
+ let globals;
+ let sandbox;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ globals.set("tokenize", tokenize);
+ globals.set("toksToTfIdfVector", toksToTfIdfVector);
+ globals.set("NaiveBayesTextTagger", NaiveBayesTextTagger);
+ globals.set("NmfTextTagger", NmfTextTagger);
+ globals.set("RecipeExecutor", RecipeExecutor);
+ instance = new PersonalityProviderWorker();
+
+ // mock the RecipeExecutor
+ instance.recipeExecutor = {
+ executeRecipe: (item, recipe) => {
+ if (recipe === "history_item_builder") {
+ if (item.title === "fail") {
+ return null;
+ }
+ return {
+ title: item.title,
+ score: item.frecency,
+ type: "history_item",
+ };
+ } else if (recipe === "interest_finalizer") {
+ return {
+ title: item.title,
+ score: item.score * 100,
+ type: "interest_vector",
+ };
+ } else if (recipe === "item_to_rank_builder") {
+ if (item.title === "fail") {
+ return null;
+ }
+ return {
+ item_title: item.title,
+ item_score: item.score,
+ type: "item_to_rank",
+ };
+ } else if (recipe === "item_ranker") {
+ if (item.title === "fail" || item.item_title === "fail") {
+ return null;
+ }
+ return {
+ title: item.title,
+ score: item.item_score * item.score,
+ type: "ranked_item",
+ };
+ }
+ return null;
+ },
+ executeCombinerRecipe: (item1, item2, recipe) => {
+ if (recipe === "interest_combiner") {
+ if (
+ item1.title === "combiner_fail" ||
+ item2.title === "combiner_fail"
+ ) {
+ return null;
+ }
+ if (item1.type === undefined) {
+ item1.type = "combined_iv";
+ }
+ if (item1.score === undefined) {
+ item1.score = 0;
+ }
+ return { type: item1.type, score: item1.score + item2.score };
+ }
+ return null;
+ },
+ };
+
+ instance.interestConfig = {
+ history_item_builder: "history_item_builder",
+ history_required_fields: ["a", "b", "c"],
+ interest_finalizer: "interest_finalizer",
+ item_to_rank_builder: "item_to_rank_builder",
+ item_ranker: "item_ranker",
+ interest_combiner: "interest_combiner",
+ };
+ });
+ afterEach(() => {
+ sinon.restore();
+ sandbox.restore();
+ globals.restore();
+ });
+ describe("#setBaseAttachmentsURL", () => {
+ it("should set baseAttachmentsURL", () => {
+ instance.setBaseAttachmentsURL("url");
+ assert.equal(instance.baseAttachmentsURL, "url");
+ });
+ });
+ describe("#setInterestConfig", () => {
+ it("should set interestConfig", () => {
+ instance.setInterestConfig("config");
+ assert.equal(instance.interestConfig, "config");
+ });
+ });
+ describe("#setInterestVector", () => {
+ it("should set interestVector", () => {
+ instance.setInterestVector("vector");
+ assert.equal(instance.interestVector, "vector");
+ });
+ });
+ describe("#onSync", async () => {
+ it("should sync remote settings collection from onSync", async () => {
+ sinon.stub(instance, "deleteAttachment").resolves();
+ sinon.stub(instance, "maybeDownloadAttachment").resolves();
+
+ instance.onSync({
+ data: {
+ created: ["create-1", "create-2"],
+ updated: [
+ { old: "update-old-1", new: "update-new-1" },
+ { old: "update-old-2", new: "update-new-2" },
+ ],
+ deleted: ["delete-2", "delete-1"],
+ },
+ });
+
+ assert(instance.maybeDownloadAttachment.withArgs("create-1").calledOnce);
+ assert(instance.maybeDownloadAttachment.withArgs("create-2").calledOnce);
+ assert(
+ instance.maybeDownloadAttachment.withArgs("update-new-1").calledOnce
+ );
+ assert(
+ instance.maybeDownloadAttachment.withArgs("update-new-2").calledOnce
+ );
+
+ assert(instance.deleteAttachment.withArgs("delete-1").calledOnce);
+ assert(instance.deleteAttachment.withArgs("delete-2").calledOnce);
+ assert(instance.deleteAttachment.withArgs("update-old-1").calledOnce);
+ assert(instance.deleteAttachment.withArgs("update-old-2").calledOnce);
+ });
+ });
+ describe("#maybeDownloadAttachment", () => {
+ it("should attempt _downloadAttachment three times for maybeDownloadAttachment", async () => {
+ let existsStub;
+ let statStub;
+ let attachmentStub;
+ sinon.stub(instance, "_downloadAttachment").resolves();
+ const makeDirStub = globals.sandbox
+ .stub(global.IOUtils, "makeDirectory")
+ .resolves();
+
+ existsStub = globals.sandbox
+ .stub(global.IOUtils, "exists")
+ .resolves(true);
+
+ statStub = globals.sandbox
+ .stub(global.IOUtils, "stat")
+ .resolves({ size: "1" });
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "1",
+ // This hash matches the hash generated from the empty Uint8Array returned by the IOUtils.read stub.
+ hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledWith(makeDirStub, "personality-provider");
+ assert.calledOnce(existsStub);
+ assert.calledOnce(statStub);
+ assert.notCalled(instance._downloadAttachment);
+
+ existsStub.resetHistory();
+ statStub.resetHistory();
+ instance._downloadAttachment.resetHistory();
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "2",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledThrice(existsStub);
+ assert.calledThrice(statStub);
+ assert.calledThrice(instance._downloadAttachment);
+
+ existsStub.resetHistory();
+ statStub.resetHistory();
+ instance._downloadAttachment.resetHistory();
+
+ attachmentStub = {
+ attachment: {
+ filename: "file",
+ size: "1",
+ // Bogus hash to trigger an update.
+ hash: "1234",
+ },
+ };
+
+ await instance.maybeDownloadAttachment(attachmentStub);
+ assert.calledThrice(existsStub);
+ assert.calledThrice(statStub);
+ assert.calledThrice(instance._downloadAttachment);
+ });
+ });
+ describe("#_downloadAttachment", () => {
+ beforeEach(() => {
+ globals.set("Uint8Array", class Uint8Array {});
+ });
+ it("should write a file from _downloadAttachment", async () => {
+ globals.set(
+ "XMLHttpRequest",
+ class {
+ constructor() {
+ this.status = 200;
+ this.response = "response!";
+ }
+ open() {}
+ setRequestHeader() {}
+ send() {}
+ }
+ );
+
+ const ioutilsWriteStub = globals.sandbox
+ .stub(global.IOUtils, "write")
+ .resolves();
+
+ await instance._downloadAttachment({
+ attachment: { location: "location", filename: "filename" },
+ });
+
+ const writeArgs = ioutilsWriteStub.firstCall.args;
+ assert.equal(writeArgs[0], "filename");
+ assert.equal(writeArgs[2].tmpPath, "filename.tmp");
+ });
+ it("should call console.error from _downloadAttachment if not valid response", async () => {
+ globals.set(
+ "XMLHttpRequest",
+ class {
+ constructor() {
+ this.status = 0;
+ this.response = "response!";
+ }
+ open() {}
+ setRequestHeader() {}
+ send() {}
+ }
+ );
+
+ const consoleErrorStub = globals.sandbox
+ .stub(console, "error")
+ .resolves();
+
+ await instance._downloadAttachment({
+ attachment: { location: "location", filename: "filename" },
+ });
+
+ assert.calledOnce(consoleErrorStub);
+ });
+ });
+ describe("#deleteAttachment", () => {
+ it("should remove attachments when calling deleteAttachment", async () => {
+ const makeDirStub = globals.sandbox
+ .stub(global.IOUtils, "makeDirectory")
+ .resolves();
+ const removeStub = globals.sandbox
+ .stub(global.IOUtils, "remove")
+ .resolves();
+ await instance.deleteAttachment({ attachment: { filename: "filename" } });
+ assert.calledOnce(makeDirStub);
+ assert.calledTwice(removeStub);
+ assert.calledWith(removeStub.firstCall, "filename", {
+ ignoreAbsent: true,
+ });
+ assert.calledWith(removeStub.secondCall, "personality-provider", {
+ ignoreAbsent: true,
+ });
+ });
+ });
+ describe("#getAttachment", () => {
+ it("should return JSON when calling getAttachment", async () => {
+ sinon.stub(instance, "maybeDownloadAttachment").resolves();
+ const readJSONStub = globals.sandbox
+ .stub(global.IOUtils, "readJSON")
+ .resolves({});
+ const record = { attachment: { filename: "filename" } };
+ let returnValue = await instance.getAttachment(record);
+
+ assert.calledOnce(readJSONStub);
+ assert.calledWith(readJSONStub, "filename");
+ assert.calledOnce(instance.maybeDownloadAttachment);
+ assert.calledWith(instance.maybeDownloadAttachment, record);
+ assert.deepEqual(returnValue, {});
+
+ readJSONStub.restore();
+ globals.sandbox.stub(global.IOUtils, "readJSON").throws("foo");
+ const consoleErrorStub = globals.sandbox
+ .stub(console, "error")
+ .resolves();
+ returnValue = await instance.getAttachment(record);
+
+ assert.calledOnce(consoleErrorStub);
+ assert.deepEqual(returnValue, {});
+ });
+ });
+ describe("#fetchModels", () => {
+ it("should return ok true", async () => {
+ sinon.stub(instance, "getAttachment").resolves();
+ const result = await instance.fetchModels([{ key: 1234 }]);
+ assert.isTrue(result.ok);
+ assert.deepEqual(instance.models, [{ recordKey: 1234 }]);
+ });
+ it("should return ok false", async () => {
+ sinon.stub(instance, "getAttachment").resolves();
+ const result = await instance.fetchModels([]);
+ assert.isTrue(!result.ok);
+ });
+ });
+ describe("#generateTaggers", () => {
+ it("should generate taggers from modelKeys", () => {
+ const modelKeys = ["nb_model_sports", "nmf_model_sports"];
+
+ instance.models = [
+ { recordKey: "nb_model_sports", model_type: "nb" },
+ {
+ recordKey: "nmf_model_sports",
+ model_type: "nmf",
+ parent_tag: "nmf_sports_parent_tag",
+ },
+ ];
+
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 1);
+ });
+ it("should skip any models not in modelKeys", () => {
+ const modelKeys = ["nb_model_sports"];
+
+ instance.models = [
+ { recordKey: "nb_model_sports", model_type: "nb" },
+ {
+ recordKey: "nmf_model_sports",
+ model_type: "nmf",
+ parent_tag: "nmf_sports_parent_tag",
+ },
+ ];
+
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0);
+ });
+ it("should skip any models not defined", () => {
+ const modelKeys = ["nb_model_sports", "nmf_model_sports"];
+
+ instance.models = [{ recordKey: "nb_model_sports", model_type: "nb" }];
+ instance.generateTaggers(modelKeys);
+ assert.equal(instance.taggers.nbTaggers.length, 1);
+ assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0);
+ });
+ });
+ describe("#generateRecipeExecutor", () => {
+ it("should generate a recipeExecutor", () => {
+ instance.recipeExecutor = null;
+ instance.taggers = {};
+ instance.generateRecipeExecutor();
+ assert.isNotNull(instance.recipeExecutor);
+ });
+ });
+ describe("#createInterestVector", () => {
+ let mockHistory = [];
+ beforeEach(() => {
+ mockHistory = [
+ {
+ title: "automotive",
+ description: "something about automotive",
+ url: "http://example.com/automotive",
+ frecency: 10,
+ },
+ {
+ title: "fashion",
+ description: "something about fashion",
+ url: "http://example.com/fashion",
+ frecency: 5,
+ },
+ {
+ title: "tech",
+ description: "something about tech",
+ url: "http://example.com/tech",
+ frecency: 1,
+ },
+ ];
+ });
+ it("should gracefully handle history entries that fail", () => {
+ mockHistory.push({ title: "fail" });
+ assert.isNotNull(instance.createInterestVector(mockHistory));
+ });
+
+ it("should fail if the combiner fails", () => {
+ mockHistory.push({ title: "combiner_fail", frecency: 111 });
+ let actual = instance.createInterestVector(mockHistory);
+ assert.isNull(actual);
+ });
+
+ it("should process history, combine, and finalize", () => {
+ let actual = instance.createInterestVector(mockHistory);
+ assert.equal(actual.interestVector.score, 1600);
+ });
+ });
+ describe("#calculateItemRelevanceScore", () => {
+ it("should return null for busted item", () => {
+ assert.equal(
+ instance.calculateItemRelevanceScore({ title: "fail" }),
+ null
+ );
+ });
+ it("should return null for a busted ranking", () => {
+ instance.interestVector = { title: "fail", score: 10 };
+ assert.equal(
+ instance.calculateItemRelevanceScore({ title: "some item", score: 6 }),
+ null
+ );
+ });
+ it("should return a score, and not change with interestVector", () => {
+ instance.interestVector = { score: 10 };
+ assert.equal(
+ instance.calculateItemRelevanceScore({ score: 2 }).rankingVector.score,
+ 20
+ );
+ assert.deepEqual(instance.interestVector, { score: 10 });
+ });
+ it("should use defined personalization_models if available", () => {
+ instance.interestVector = { score: 10 };
+ const item = {
+ score: 2,
+ personalization_models: {
+ entertainment: 1,
+ },
+ };
+ assert.equal(
+ instance.calculateItemRelevanceScore(item).scorableItem.item_tags
+ .entertainment,
+ 1
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js
new file mode 100644
index 0000000000..82a1f2b77a
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js
@@ -0,0 +1,1543 @@
+import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm";
+import { tokenize } from "lib/PersonalityProvider/Tokenize.jsm";
+
+class MockTagger {
+ constructor(mode, tagScoreMap) {
+ this.mode = mode;
+ this.tagScoreMap = tagScoreMap;
+ }
+ tagTokens(tokens) {
+ if (this.mode === "nb") {
+ // eslint-disable-next-line prefer-destructuring
+ let tag = Object.keys(this.tagScoreMap)[0];
+ // eslint-disable-next-line prefer-destructuring
+ let prob = this.tagScoreMap[tag];
+ let conf = prob >= 0.85;
+ return {
+ label: tag,
+ logProb: Math.log(prob),
+ confident: conf,
+ };
+ }
+ return this.tagScoreMap;
+ }
+ tag(text) {
+ return this.tagTokens([text]);
+ }
+}
+
+describe("RecipeExecutor", () => {
+ let makeItem = () => {
+ let x = {
+ lhs: 2,
+ one: 1,
+ two: 2,
+ three: 3,
+ foo: "FOO",
+ bar: "BAR",
+ baz: ["one", "two", "three"],
+ qux: 42,
+ text: "This Is A_sentence.",
+ url: "http://www.wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4",
+ url2: "http://wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4",
+ map: {
+ c: 3,
+ a: 1,
+ b: 2,
+ },
+ map2: {
+ b: 2,
+ c: 3,
+ d: 4,
+ },
+ arr1: [2, 3, 4],
+ arr2: [3, 4, 5],
+ long: [3, 4, 5, 6, 7],
+ tags: {
+ a: {
+ aa: 0.1,
+ ab: 0.2,
+ ac: 0.3,
+ },
+ b: {
+ ba: 4,
+ bb: 5,
+ bc: 6,
+ },
+ },
+ bogus: {
+ a: {
+ aa: "0.1",
+ ab: "0.2",
+ ac: "0.3",
+ },
+ b: {
+ ba: "4",
+ bb: "5",
+ bc: "6",
+ },
+ },
+ zero: {
+ a: 0,
+ b: 0,
+ },
+ zaro: [0, 0],
+ };
+ return x;
+ };
+
+ let EPSILON = 0.00001;
+
+ let instance = new RecipeExecutor(
+ [
+ new MockTagger("nb", { tag1: 0.7 }),
+ new MockTagger("nb", { tag2: 0.86 }),
+ new MockTagger("nb", { tag3: 0.9 }),
+ new MockTagger("nb", { tag5: 0.9 }),
+ ],
+ {
+ tag1: new MockTagger("nmf", {
+ tag11: 0.9,
+ tag12: 0.8,
+ tag13: 0.7,
+ }),
+ tag2: new MockTagger("nmf", {
+ tag21: 0.8,
+ tag22: 0.7,
+ tag23: 0.6,
+ }),
+ tag3: new MockTagger("nmf", {
+ tag31: 0.7,
+ tag32: 0.6,
+ tag33: 0.5,
+ }),
+ tag4: new MockTagger("nmf", { tag41: 0.99 }),
+ },
+ tokenize
+ );
+ let item = null;
+
+ beforeEach(() => {
+ item = makeItem();
+ });
+
+ describe("#_assembleText", () => {
+ it("should simply copy a single string", () => {
+ assert.equal(instance._assembleText(item, ["foo"]), "FOO");
+ });
+ it("should append some strings with a space", () => {
+ assert.equal(instance._assembleText(item, ["foo", "bar"]), "FOO BAR");
+ });
+ it("should give an empty string for a missing field", () => {
+ assert.equal(instance._assembleText(item, ["missing"]), "");
+ });
+ it("should not double space an interior missing field", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "missing", "bar"]),
+ "FOO BAR"
+ );
+ });
+ it("should splice in an array of strings", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "baz", "bar"]),
+ "FOO one two three BAR"
+ );
+ });
+ it("should handle numbers", () => {
+ assert.equal(
+ instance._assembleText(item, ["foo", "qux", "bar"]),
+ "FOO 42 BAR"
+ );
+ });
+ });
+
+ describe("#naiveBayesTag", () => {
+ it("should understand NaiveBayesTextTagger", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ assert.isTrue("nb_tags" in item);
+ assert.isTrue(!("tag1" in item.nb_tags));
+ assert.equal(item.nb_tags.tag2, 0.86);
+ assert.equal(item.nb_tags.tag3, 0.9);
+ assert.equal(item.nb_tags.tag5, 0.9);
+ assert.isTrue("nb_tokens" in item);
+ assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]);
+ assert.isTrue("nb_tags_extended" in item);
+ assert.isTrue(!("tag1" in item.nb_tags_extended));
+ assert.deepEqual(item.nb_tags_extended.tag2, {
+ label: "tag2",
+ logProb: Math.log(0.86),
+ confident: true,
+ });
+ assert.deepEqual(item.nb_tags_extended.tag3, {
+ label: "tag3",
+ logProb: Math.log(0.9),
+ confident: true,
+ });
+ assert.deepEqual(item.nb_tags_extended.tag5, {
+ label: "tag5",
+ logProb: Math.log(0.9),
+ confident: true,
+ });
+ assert.isTrue("nb_tokens" in item);
+ assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]);
+ });
+ });
+
+ describe("#conditionallyNmfTag", () => {
+ it("should do nothing if it's not nb tagged", () => {
+ item = instance.conditionallyNmfTag(item, {});
+ assert.equal(item, null);
+ });
+ it("should populate nmf tags for the nb tags", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ item = instance.conditionallyNmfTag(item, {});
+ assert.isTrue("nb_tags" in item);
+ assert.deepEqual(item.nmf_tags, {
+ tag2: {
+ tag21: 0.8,
+ tag22: 0.7,
+ tag23: 0.6,
+ },
+ tag3: {
+ tag31: 0.7,
+ tag32: 0.6,
+ tag33: 0.5,
+ },
+ });
+ assert.deepEqual(item.nmf_tags_parent, {
+ tag21: "tag2",
+ tag22: "tag2",
+ tag23: "tag2",
+ tag31: "tag3",
+ tag32: "tag3",
+ tag33: "tag3",
+ });
+ });
+ it("should not populate nmf tags for things that were not nb tagged", () => {
+ item = instance.naiveBayesTag(item, { fields: ["text"] });
+ item = instance.conditionallyNmfTag(item, {});
+ assert.isTrue("nmf_tags" in item);
+ assert.isTrue(!("tag4" in item.nmf_tags));
+ assert.isTrue("nmf_tags_parent" in item);
+ assert.isTrue(!("tag4" in item.nmf_tags_parent));
+ });
+ });
+
+ describe("#acceptItemByFieldValue", () => {
+ it("should implement ==", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsValue: 3,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "two",
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "three",
+ }) === null
+ );
+ });
+ it("should implement !=", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "!=",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "!=",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement < ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement <= ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "<=",
+ rhsValue: 3,
+ }) !== null
+ );
+ });
+ it("should implement > ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 1,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 2,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">",
+ rhsValue: 3,
+ }) === null
+ );
+ });
+ it("should implement >= ", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 1,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 2,
+ }) !== null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: ">=",
+ rhsValue: 3,
+ }) === null
+ );
+ });
+ it("should skip items with missing fields", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "no-left",
+ op: "==",
+ rhsValue: 1,
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "==",
+ rhsField: "no-right",
+ }) === null
+ );
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, { field: "lhs", op: "==" }) ===
+ null
+ );
+ });
+ it("should skip items with bogus operators", () => {
+ assert.isTrue(
+ instance.acceptItemByFieldValue(item, {
+ field: "lhs",
+ op: "bogus",
+ rhsField: "two",
+ }) === null
+ );
+ });
+ });
+
+ describe("#tokenizeUrl", () => {
+ it("should strip the leading www from a url", () => {
+ item = instance.tokenizeUrl(item, { field: "url", dest: "url_toks" });
+ assert.deepEqual(
+ [
+ "wonder",
+ "example",
+ "com",
+ "dir1",
+ "dir2a",
+ "dir2b",
+ "dir3",
+ "4",
+ "key1",
+ "key2",
+ "val2",
+ "key3",
+ "amp",
+ "3",
+ "4",
+ ],
+ item.url_toks
+ );
+ });
+ it("should tokenize the not strip the leading non-wwww token from a url", () => {
+ item = instance.tokenizeUrl(item, { field: "url2", dest: "url_toks" });
+ assert.deepEqual(
+ [
+ "wonder",
+ "example",
+ "com",
+ "dir1",
+ "dir2a",
+ "dir2b",
+ "dir3",
+ "4",
+ "key1",
+ "key2",
+ "val2",
+ "key3",
+ "amp",
+ "3",
+ "4",
+ ],
+ item.url_toks
+ );
+ });
+ it("should error for a missing url", () => {
+ item = instance.tokenizeUrl(item, { field: "missing", dest: "url_toks" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#getUrlDomain", () => {
+ it("should get only the hostname skipping the www", () => {
+ item = instance.getUrlDomain(item, { field: "url", dest: "url_domain" });
+ assert.isTrue("url_domain" in item);
+ assert.deepEqual("wonder.example.com", item.url_domain);
+ });
+ it("should get only the hostname", () => {
+ item = instance.getUrlDomain(item, { field: "url2", dest: "url_domain" });
+ assert.isTrue("url_domain" in item);
+ assert.deepEqual("wonder.example.com", item.url_domain);
+ });
+ it("should get the hostname and 2 levels of directories", () => {
+ item = instance.getUrlDomain(item, {
+ field: "url",
+ path_length: 2,
+ dest: "url_plus_2",
+ });
+ assert.isTrue("url_plus_2" in item);
+ assert.deepEqual("wonder.example.com/dir1/dir2a-dir2b", item.url_plus_2);
+ });
+ it("should error for a missing url", () => {
+ item = instance.getUrlDomain(item, {
+ field: "missing",
+ dest: "url_domain",
+ });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#tokenizeField", () => {
+ it("should tokenize the field", () => {
+ item = instance.tokenizeField(item, { field: "text", dest: "toks" });
+ assert.isTrue("toks" in item);
+ assert.deepEqual(["this", "is", "a", "sentence"], item.toks);
+ });
+ it("should error for a missing field", () => {
+ item = instance.tokenizeField(item, { field: "missing", dest: "toks" });
+ assert.equal(item, null);
+ });
+ it("should error for a broken config", () => {
+ item = instance.tokenizeField(item, {});
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#_typeOf", () => {
+ it("should know this is a map", () => {
+ assert.equal(instance._typeOf({}), "map");
+ });
+ it("should know this is an array", () => {
+ assert.equal(instance._typeOf([]), "array");
+ });
+ it("should know this is a string", () => {
+ assert.equal(instance._typeOf("blah"), "string");
+ });
+ it("should know this is a boolean", () => {
+ assert.equal(instance._typeOf(true), "boolean");
+ });
+
+ it("should know this is a null", () => {
+ assert.equal(instance._typeOf(null), "null");
+ });
+ });
+
+ describe("#_lookupScalar", () => {
+ it("should return the constant", () => {
+ assert.equal(instance._lookupScalar({}, 1, 0), 1);
+ });
+ it("should return the default", () => {
+ assert.equal(instance._lookupScalar({}, "blah", 42), 42);
+ });
+ it("should return the field's value", () => {
+ assert.equal(instance._lookupScalar({ blah: 11 }, "blah", 42), 11);
+ });
+ });
+
+ describe("#copyValue", () => {
+ it("should copy values", () => {
+ item = instance.copyValue(item, { src: "one", dest: "again" });
+ assert.isTrue("again" in item);
+ assert.equal(item.again, 1);
+ item.one = 100;
+ assert.equal(item.one, 100);
+ assert.equal(item.again, 1);
+ });
+ it("should handle maps corrects", () => {
+ item = instance.copyValue(item, { src: "map", dest: "again" });
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ item.map.c = 100;
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ item.map = 342;
+ assert.deepEqual(item.again, { a: 1, b: 2, c: 3 });
+ });
+ it("should error for a missing field", () => {
+ item = instance.copyValue(item, { src: "missing", dest: "toks" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#keepTopK", () => {
+ it("should keep the 2 smallest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2, descending: false });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue("a" in item.map);
+ assert.equal(item.map.a, 1);
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue(!("c" in item.map));
+ });
+ it("should keep the 2 largest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2, descending: true });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue(!("a" in item.map));
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 3);
+ });
+ it("should still keep the 2 largest", () => {
+ item = instance.keepTopK(item, { field: "map", k: 2 });
+ assert.equal(Object.keys(item.map).length, 2);
+ assert.isTrue(!("a" in item.map));
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 2);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 3);
+ });
+ it("should promote up nested fields", () => {
+ item = instance.keepTopK(item, { field: "tags", k: 2 });
+ assert.equal(Object.keys(item.tags).length, 2);
+ assert.deepEqual(item.tags, { bb: 5, bc: 6 });
+ });
+ it("should error for a missing field", () => {
+ item = instance.keepTopK(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#scalarMultiply", () => {
+ it("should use constants", () => {
+ item = instance.scalarMultiply(item, { field: "map", k: 2 });
+ assert.equal(item.map.a, 2);
+ assert.equal(item.map.b, 4);
+ assert.equal(item.map.c, 6);
+ });
+ it("should use fields", () => {
+ item = instance.scalarMultiply(item, { field: "map", k: "three" });
+ assert.equal(item.map.a, 3);
+ assert.equal(item.map.b, 6);
+ assert.equal(item.map.c, 9);
+ });
+ it("should use default", () => {
+ item = instance.scalarMultiply(item, {
+ field: "map",
+ k: "missing",
+ dfault: 4,
+ });
+ assert.equal(item.map.a, 4);
+ assert.equal(item.map.b, 8);
+ assert.equal(item.map.c, 12);
+ });
+ it("should error for a missing field", () => {
+ item = instance.scalarMultiply(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ it("should multiply numbers", () => {
+ item = instance.scalarMultiply(item, { field: "lhs", k: 2 });
+ assert.equal(item.lhs, 4);
+ });
+ it("should multiply arrays", () => {
+ item = instance.scalarMultiply(item, { field: "arr1", k: 2 });
+ assert.deepEqual(item.arr1, [4, 6, 8]);
+ });
+ it("should should error on strings", () => {
+ item = instance.scalarMultiply(item, { field: "foo", k: 2 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#elementwiseMultiply", () => {
+ it("should handle maps", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "tags",
+ right: "map2",
+ });
+ assert.deepEqual(item.tags, {
+ a: { aa: 0, ab: 0, ac: 0 },
+ b: { ba: 8, bb: 10, bc: 12 },
+ });
+ });
+ it("should handle arrays of same length", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "arr2",
+ });
+ assert.deepEqual(item.arr1, [6, 12, 20]);
+ });
+ it("should error for arrays of different lengths", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "long",
+ });
+ assert.equal(item, null);
+ });
+ it("should error for a missing left", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "missing",
+ right: "arr2",
+ });
+ assert.equal(item, null);
+ });
+ it("should error for a missing right", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "arr1",
+ right: "missing",
+ });
+ assert.equal(item, null);
+ });
+ it("should handle numbers", () => {
+ item = instance.elementwiseMultiply(item, {
+ left: "three",
+ right: "two",
+ });
+ assert.equal(item.three, 6);
+ });
+ it("should error for mismatched types", () => {
+ item = instance.elementwiseMultiply(item, { left: "arr1", right: "two" });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.elementwiseMultiply(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#vectorMultiply", () => {
+ it("should calculate dot products from maps", () => {
+ item = instance.vectorMultiply(item, {
+ left: "map",
+ right: "map2",
+ dest: "dot",
+ });
+ assert.equal(item.dot, 13);
+ });
+ it("should calculate dot products from arrays", () => {
+ item = instance.vectorMultiply(item, {
+ left: "arr1",
+ right: "arr2",
+ dest: "dot",
+ });
+ assert.equal(item.dot, 38);
+ });
+ it("should error for arrays of different lengths", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "long" });
+ assert.equal(item, null);
+ });
+ it("should error for a missing left", () => {
+ item = instance.vectorMultiply(item, { left: "missing", right: "arr2" });
+ assert.equal(item, null);
+ });
+ it("should error for a missing right", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error for mismatched types", () => {
+ item = instance.vectorMultiply(item, { left: "arr1", right: "two" });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.vectorMultiply(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#scalarAdd", () => {
+ it("should error for a missing field", () => {
+ item = instance.scalarAdd(item, { field: "missing", k: 10 });
+ assert.equal(item, null);
+ });
+ it("should error for strings", () => {
+ item = instance.scalarAdd(item, { field: "foo", k: 10 });
+ assert.equal(item, null);
+ });
+ it("should work for numbers", () => {
+ item = instance.scalarAdd(item, { field: "one", k: 10 });
+ assert.equal(item.one, 11);
+ });
+ it("should add a constant to every cell on a map", () => {
+ item = instance.scalarAdd(item, { field: "map", k: 10 });
+ assert.deepEqual(item.map, { a: 11, b: 12, c: 13 });
+ });
+ it("should add a value from a field to every cell on a map", () => {
+ item = instance.scalarAdd(item, { field: "map", k: "qux" });
+ assert.deepEqual(item.map, { a: 43, b: 44, c: 45 });
+ });
+ it("should add a constant to every cell on an array", () => {
+ item = instance.scalarAdd(item, { field: "arr1", k: 10 });
+ assert.deepEqual(item.arr1, [12, 13, 14]);
+ });
+ });
+
+ describe("#vectorAdd", () => {
+ it("should calculate add vectors from maps", () => {
+ item = instance.vectorAdd(item, { left: "map", right: "map2" });
+ assert.equal(Object.keys(item.map).length, 4);
+ assert.isTrue("a" in item.map);
+ assert.equal(item.map.a, 1);
+ assert.isTrue("b" in item.map);
+ assert.equal(item.map.b, 4);
+ assert.isTrue("c" in item.map);
+ assert.equal(item.map.c, 6);
+ assert.isTrue("d" in item.map);
+ assert.equal(item.map.d, 4);
+ });
+ it("should work for missing left", () => {
+ item = instance.vectorAdd(item, { left: "missing", right: "arr2" });
+ assert.deepEqual(item.missing, [3, 4, 5]);
+ });
+ it("should error for missing right", () => {
+ item = instance.vectorAdd(item, { left: "arr2", right: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error error for strings", () => {
+ item = instance.vectorAdd(item, { left: "foo", right: "bar" });
+ assert.equal(item, null);
+ });
+ it("should error for different types", () => {
+ item = instance.vectorAdd(item, { left: "arr2", right: "map" });
+ assert.equal(item, null);
+ });
+ it("should calculate add vectors from arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "arr2" });
+ assert.deepEqual(item.arr1, [5, 7, 9]);
+ });
+ it("should abort on different sized arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "long" });
+ assert.equal(item, null);
+ });
+ it("should calculate add vectors from arrays", () => {
+ item = instance.vectorAdd(item, { left: "arr1", right: "arr2" });
+ assert.deepEqual(item.arr1, [5, 7, 9]);
+ });
+ });
+
+ describe("#makeBoolean", () => {
+ it("should error for missing field", () => {
+ item = instance.makeBoolean(item, { field: "missing", threshold: 2 });
+ assert.equal(item, null);
+ });
+ it("should 0/1 a map", () => {
+ item = instance.makeBoolean(item, { field: "map", threshold: 2 });
+ assert.deepEqual(item.map, { a: 0, b: 0, c: 1 });
+ });
+ it("should a map of all 1s", () => {
+ item = instance.makeBoolean(item, { field: "map" });
+ assert.deepEqual(item.map, { a: 1, b: 1, c: 1 });
+ });
+ it("should -1/1 a map", () => {
+ item = instance.makeBoolean(item, {
+ field: "map",
+ threshold: 2,
+ keep_negative: true,
+ });
+ assert.deepEqual(item.map, { a: -1, b: -1, c: 1 });
+ });
+ it("should work an array", () => {
+ item = instance.makeBoolean(item, { field: "arr1", threshold: 3 });
+ assert.deepEqual(item.arr1, [0, 0, 1]);
+ });
+ it("should -1/1 an array", () => {
+ item = instance.makeBoolean(item, {
+ field: "arr1",
+ threshold: 3,
+ keep_negative: true,
+ });
+ assert.deepEqual(item.arr1, [-1, -1, 1]);
+ });
+ it("should 1 a high number", () => {
+ item = instance.makeBoolean(item, { field: "qux", threshold: 3 });
+ assert.equal(item.qux, 1);
+ });
+ it("should 0 a low number", () => {
+ item = instance.makeBoolean(item, { field: "qux", threshold: 70 });
+ assert.equal(item.qux, 0);
+ });
+ it("should -1 a low number", () => {
+ item = instance.makeBoolean(item, {
+ field: "qux",
+ threshold: 83,
+ keep_negative: true,
+ });
+ assert.equal(item.qux, -1);
+ });
+ it("should fail a string", () => {
+ item = instance.makeBoolean(item, { field: "foo", threshold: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#allowFields", () => {
+ it("should filter the keys out of a map", () => {
+ item = instance.allowFields(item, {
+ fields: ["foo", "missing", "bar"],
+ });
+ assert.deepEqual(item, { foo: "FOO", bar: "BAR" });
+ });
+ });
+
+ describe("#filterByValue", () => {
+ it("should fail on missing field", () => {
+ item = instance.filterByValue(item, { field: "missing", threshold: 2 });
+ assert.equal(item, null);
+ });
+ it("should filter the keys out of a map", () => {
+ item = instance.filterByValue(item, { field: "map", threshold: 2 });
+ assert.deepEqual(item.map, { c: 3 });
+ });
+ });
+
+ describe("#l2Normalize", () => {
+ it("should fail on missing field", () => {
+ item = instance.l2Normalize(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should L2 normalize an array", () => {
+ item = instance.l2Normalize(item, { field: "arr1" });
+ assert.deepEqual(
+ item.arr1,
+ [0.3713906763541037, 0.5570860145311556, 0.7427813527082074]
+ );
+ });
+ it("should L2 normalize a map", () => {
+ item = instance.l2Normalize(item, { field: "map" });
+ assert.deepEqual(item.map, {
+ a: 0.2672612419124244,
+ b: 0.5345224838248488,
+ c: 0.8017837257372732,
+ });
+ });
+ it("should fail a string", () => {
+ item = instance.l2Normalize(item, { field: "foo" });
+ assert.equal(item, null);
+ });
+ it("should not bomb on a zero vector", () => {
+ item = instance.l2Normalize(item, { field: "zero" });
+ assert.deepEqual(item.zero, { a: 0, b: 0 });
+ item = instance.l2Normalize(item, { field: "zaro" });
+ assert.deepEqual(item.zaro, [0, 0]);
+ });
+ });
+
+ describe("#probNormalize", () => {
+ it("should fail on missing field", () => {
+ item = instance.probNormalize(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should normalize an array to sum to 1", () => {
+ item = instance.probNormalize(item, { field: "arr1" });
+ assert.deepEqual(
+ item.arr1,
+ [0.2222222222222222, 0.3333333333333333, 0.4444444444444444]
+ );
+ });
+ it("should normalize a map to sum to 1", () => {
+ item = instance.probNormalize(item, { field: "map" });
+ assert.equal(Object.keys(item.map).length, 3);
+ assert.isTrue("a" in item.map);
+ assert.isTrue(Math.abs(item.map.a - 0.16667) <= EPSILON);
+ assert.isTrue("b" in item.map);
+ assert.isTrue(Math.abs(item.map.b - 0.33333) <= EPSILON);
+ assert.isTrue("c" in item.map);
+ assert.isTrue(Math.abs(item.map.c - 0.5) <= EPSILON);
+ });
+ it("should fail a string", () => {
+ item = instance.probNormalize(item, { field: "foo" });
+ assert.equal(item, null);
+ });
+ it("should not bomb on a zero vector", () => {
+ item = instance.probNormalize(item, { field: "zero" });
+ assert.deepEqual(item.zero, { a: 0, b: 0 });
+ item = instance.probNormalize(item, { field: "zaro" });
+ assert.deepEqual(item.zaro, [0, 0]);
+ });
+ });
+
+ describe("#scalarMultiplyTag", () => {
+ it("should fail on missing field", () => {
+ item = instance.scalarMultiplyTag(item, { field: "missing", k: 3 });
+ assert.equal(item, null);
+ });
+ it("should scalar multiply a nested map", () => {
+ item = instance.scalarMultiplyTag(item, {
+ field: "tags",
+ k: 3,
+ log_scale: false,
+ });
+ assert.isTrue(Math.abs(item.tags.a.aa - 0.3) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ab - 0.6) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ac - 0.9) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.ba - 12) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bb - 15) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bc - 18) <= EPSILON);
+ });
+ it("should scalar multiply a nested map with logrithms", () => {
+ item = instance.scalarMultiplyTag(item, {
+ field: "tags",
+ k: 3,
+ log_scale: true,
+ });
+ assert.isTrue(
+ Math.abs(item.tags.a.aa - Math.log(0.1 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.a.ab - Math.log(0.2 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.a.ac - Math.log(0.3 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.ba - Math.log(4.0 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.bb - Math.log(5.0 + 0.000001) * 3) <= EPSILON
+ );
+ assert.isTrue(
+ Math.abs(item.tags.b.bc - Math.log(6.0 + 0.000001) * 3) <= EPSILON
+ );
+ });
+ it("should fail a string", () => {
+ item = instance.scalarMultiplyTag(item, { field: "foo", k: 3 });
+ assert.equal(item, null);
+ });
+ });
+
+ describe("#setDefault", () => {
+ it("should store a missing value", () => {
+ item = instance.setDefault(item, { field: "missing", value: 1111 });
+ assert.equal(item.missing, 1111);
+ });
+ it("should not overwrite an existing value", () => {
+ item = instance.setDefault(item, { field: "lhs", value: 1111 });
+ assert.equal(item.lhs, 2);
+ });
+ it("should store a complex value", () => {
+ item = instance.setDefault(item, { field: "missing", value: { a: 1 } });
+ assert.deepEqual(item.missing, { a: 1 });
+ });
+ });
+
+ describe("#lookupValue", () => {
+ it("should promote a value", () => {
+ item = instance.lookupValue(item, {
+ haystack: "map",
+ needle: "c",
+ dest: "ccc",
+ });
+ assert.equal(item.ccc, 3);
+ });
+ it("should handle a missing haystack", () => {
+ item = instance.lookupValue(item, {
+ haystack: "missing",
+ needle: "c",
+ dest: "ccc",
+ });
+ assert.isTrue(!("ccc" in item));
+ });
+ it("should handle a missing needle", () => {
+ item = instance.lookupValue(item, {
+ haystack: "map",
+ needle: "missing",
+ dest: "ccc",
+ });
+ assert.isTrue(!("ccc" in item));
+ });
+ });
+
+ describe("#copyToMap", () => {
+ it("should copy a value to a map", () => {
+ item = instance.copyToMap(item, {
+ src: "qux",
+ dest_map: "map",
+ dest_key: "zzz",
+ });
+ assert.isTrue("zzz" in item.map);
+ assert.equal(item.map.zzz, item.qux);
+ });
+ it("should create a new map to hold the key", () => {
+ item = instance.copyToMap(item, {
+ src: "qux",
+ dest_map: "missing",
+ dest_key: "zzz",
+ });
+ assert.equal(Object.keys(item.missing).length, 1);
+ assert.equal(item.missing.zzz, item.qux);
+ });
+ it("should not create an empty map if the src is missing", () => {
+ item = instance.copyToMap(item, {
+ src: "missing",
+ dest_map: "no_map",
+ dest_key: "zzz",
+ });
+ assert.isTrue(!("no_map" in item));
+ });
+ });
+
+ describe("#applySoftmaxTags", () => {
+ it("should error on missing field", () => {
+ item = instance.applySoftmaxTags(item, { field: "missing" });
+ assert.equal(item, null);
+ });
+ it("should error on nonmaps", () => {
+ item = instance.applySoftmaxTags(item, { field: "arr1" });
+ assert.equal(item, null);
+ });
+ it("should error on unnested maps", () => {
+ item = instance.applySoftmaxTags(item, { field: "map" });
+ assert.equal(item, null);
+ });
+ it("should error on wrong nested maps", () => {
+ item = instance.applySoftmaxTags(item, { field: "bogus" });
+ assert.equal(item, null);
+ });
+ it("should apply softmax across the subtags", () => {
+ item = instance.applySoftmaxTags(item, { field: "tags" });
+ assert.isTrue("a" in item.tags);
+ assert.isTrue("aa" in item.tags.a);
+ assert.isTrue("ab" in item.tags.a);
+ assert.isTrue("ac" in item.tags.a);
+ assert.isTrue(Math.abs(item.tags.a.aa - 0.30061) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ab - 0.33222) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.a.ac - 0.36717) <= EPSILON);
+
+ assert.isTrue("b" in item.tags);
+ assert.isTrue("ba" in item.tags.b);
+ assert.isTrue("bb" in item.tags.b);
+ assert.isTrue("bc" in item.tags.b);
+ assert.isTrue(Math.abs(item.tags.b.ba - 0.09003) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bb - 0.24473) <= EPSILON);
+ assert.isTrue(Math.abs(item.tags.b.bc - 0.66524) <= EPSILON);
+ });
+ });
+
+ describe("#combinerAdd", () => {
+ it("should do nothing when right field is missing", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "missing" });
+ assert.deepEqual(combined, item);
+ });
+ it("should handle missing left maps", () => {
+ let right = makeItem();
+ right.missingmap = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerAdd(item, right, { field: "missingmap" });
+ assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 });
+ });
+ it("should add equal sized maps", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6 });
+ });
+ it("should add long map to short map", () => {
+ let right = makeItem();
+ right.map.d = 999;
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 });
+ });
+ it("should add short map to long map", () => {
+ let right = makeItem();
+ item.map.d = 999;
+ let combined = instance.combinerAdd(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 });
+ });
+ it("should add equal sized arrays", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8]);
+ });
+ it("should handle missing left arrays", () => {
+ let right = makeItem();
+ right.missingarray = [5, 1, 4];
+ let combined = instance.combinerAdd(item, right, {
+ field: "missingarray",
+ });
+ assert.deepEqual(combined.missingarray, [5, 1, 4]);
+ });
+ it("should add long array to short array", () => {
+ let right = makeItem();
+ right.arr1 = [2, 3, 4, 12];
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8, 12]);
+ });
+ it("should add short array to long array", () => {
+ let right = makeItem();
+ item.arr1 = [2, 3, 4, 12];
+ let combined = instance.combinerAdd(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [4, 6, 8, 12]);
+ });
+ it("should handle missing left number", () => {
+ let right = makeItem();
+ right.missingnumber = 999;
+ let combined = instance.combinerAdd(item, right, {
+ field: "missingnumber",
+ });
+ assert.deepEqual(combined.missingnumber, 999);
+ });
+ it("should add numbers", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 4);
+ });
+ it("should error on missing left, and right is a string", () => {
+ let right = makeItem();
+ right.error = "error";
+ let combined = instance.combinerAdd(item, right, { field: "error" });
+ assert.equal(combined, null);
+ });
+ it("should error on left string", () => {
+ let right = makeItem();
+ let combined = instance.combinerAdd(item, right, { field: "foo" });
+ assert.equal(combined, null);
+ });
+ it("should error on mismatch types", () => {
+ let right = makeItem();
+ right.lhs = [1, 2, 3];
+ let combined = instance.combinerAdd(item, right, { field: "lhs" });
+ assert.equal(combined, null);
+ });
+ });
+
+ describe("#combinerMax", () => {
+ it("should do nothing when right field is missing", () => {
+ let right = makeItem();
+ let combined = instance.combinerMax(item, right, { field: "missing" });
+ assert.deepEqual(combined, item);
+ });
+ it("should handle missing left maps", () => {
+ let right = makeItem();
+ right.missingmap = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerMax(item, right, { field: "missingmap" });
+ assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 });
+ });
+ it("should handle equal sized maps", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3 };
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3 });
+ });
+ it("should handle short map to long map", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3, d: 999 };
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 });
+ });
+ it("should handle long map to short map", () => {
+ let right = makeItem();
+ right.map = { a: 5, b: -1, c: 3 };
+ item.map.d = 999;
+ let combined = instance.combinerMax(item, right, { field: "map" });
+ assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 });
+ });
+ it("should handle equal sized arrays", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4];
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4]);
+ });
+ it("should handle missing left arrays", () => {
+ let right = makeItem();
+ right.missingarray = [5, 1, 4];
+ let combined = instance.combinerMax(item, right, {
+ field: "missingarray",
+ });
+ assert.deepEqual(combined.missingarray, [5, 1, 4]);
+ });
+ it("should handle short array to long array", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4, 7];
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4, 7]);
+ });
+ it("should handle long array to short array", () => {
+ let right = makeItem();
+ right.arr1 = [5, 1, 4];
+ item.arr1.push(7);
+ let combined = instance.combinerMax(item, right, { field: "arr1" });
+ assert.deepEqual(combined.arr1, [5, 3, 4, 7]);
+ });
+ it("should handle missing left number", () => {
+ let right = makeItem();
+ right.missingnumber = 999;
+ let combined = instance.combinerMax(item, right, {
+ field: "missingnumber",
+ });
+ assert.deepEqual(combined.missingnumber, 999);
+ });
+ it("should handle big number", () => {
+ let right = makeItem();
+ right.lhs = 99;
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 99);
+ });
+ it("should handle small number", () => {
+ let right = makeItem();
+ item.lhs = 99;
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined.lhs, 99);
+ });
+ it("should error on missing left, and right is a string", () => {
+ let right = makeItem();
+ right.error = "error";
+ let combined = instance.combinerMax(item, right, { field: "error" });
+ assert.equal(combined, null);
+ });
+ it("should error on left string", () => {
+ let right = makeItem();
+ let combined = instance.combinerMax(item, right, { field: "foo" });
+ assert.equal(combined, null);
+ });
+ it("should error on mismatch types", () => {
+ let right = makeItem();
+ right.lhs = [1, 2, 3];
+ let combined = instance.combinerMax(item, right, { field: "lhs" });
+ assert.equal(combined, null);
+ });
+ });
+
+ describe("#combinerCollectValues", () => {
+ it("should error on bogus operation", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "missing",
+ });
+ assert.equal(combined, null);
+ });
+ it("should sum when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should sum when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should sum when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "sum",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 82,
+ });
+ });
+
+ it("should max when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should max when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should max when both (right)", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 99;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 99,
+ });
+ });
+ it("should max when both (left)", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = -99;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "max",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+
+ it("should overwrite when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+ it("should overwrite when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should overwrite when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 77 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "overwrite",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 41,
+ });
+ });
+
+ it("should count when missing left", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, {
+ "maseratiusa.com/maserati": 1,
+ });
+ });
+ it("should count when missing right", () => {
+ let right = makeItem();
+ item.combined_map = { fake: 42 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, { fake: 42 });
+ });
+ it("should count when both", () => {
+ let right = makeItem();
+ right.url_domain = "maseratiusa.com/maserati";
+ right.time = 41;
+ item.combined_map = { fake: 42, "maseratiusa.com/maserati": 1 };
+ let combined = instance.combinerCollectValues(item, right, {
+ left_field: "combined_map",
+ right_key_field: "url_domain",
+ right_value_field: "time",
+ operation: "count",
+ });
+ assert.deepEqual(combined.combined_map, {
+ fake: 42,
+ "maseratiusa.com/maserati": 2,
+ });
+ });
+ });
+
+ describe("#executeRecipe", () => {
+ it("should handle working steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final.foo, 1);
+ assert.equal(final.bar, 10);
+ });
+ it("should handle unknown steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ { function: "missing" },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final, null);
+ });
+ it("should handle erroring steps", () => {
+ let final = instance.executeRecipe({}, [
+ { function: "set_default", field: "foo", value: 1 },
+ {
+ function: "accept_item_by_field_value",
+ field: "missing",
+ op: "invalid",
+ rhsField: "moot",
+ rhsValue: "m00t",
+ },
+ { function: "set_default", field: "bar", value: 10 },
+ ]);
+ assert.equal(final, null);
+ });
+ });
+
+ describe("#executeCombinerRecipe", () => {
+ it("should handle working steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10 },
+ { foo: 1, bar: 10 },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final.foo, 2);
+ assert.equal(final.bar, 20);
+ });
+ it("should handle unknown steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10 },
+ { foo: 1, bar: 10 },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "missing" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final, null);
+ });
+ it("should handle erroring steps", () => {
+ let final = instance.executeCombinerRecipe(
+ { foo: 1, bar: 10, baz: 0 },
+ { foo: 1, bar: 10, baz: "hundred" },
+ [
+ { function: "combiner_add", field: "foo" },
+ { function: "combiner_add", field: "baz" },
+ { function: "combiner_add", field: "bar" },
+ ]
+ );
+ assert.equal(final, null);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js
new file mode 100644
index 0000000000..8503c2903b
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js
@@ -0,0 +1,134 @@
+import {
+ tokenize,
+ toksToTfIdfVector,
+} from "lib/PersonalityProvider/Tokenize.jsm";
+
+const EPSILON = 0.00001;
+
+describe("TF-IDF Term Vectorizer", () => {
+ describe("#tokenize", () => {
+ let testCases = [
+ { input: "HELLO there", expected: ["hello", "there"] },
+ { input: "blah,,,blah,blah", expected: ["blah", "blah", "blah"] },
+ {
+ input: "Call Jenny: 967-5309",
+ expected: ["call", "jenny", "967", "5309"],
+ },
+ {
+ input: "Yo(what)[[hello]]{{jim}}}bob{1:2:1+2=$3",
+ expected: [
+ "yo",
+ "what",
+ "hello",
+ "jim",
+ "bob",
+ "1",
+ "2",
+ "1",
+ "2",
+ "3",
+ ],
+ },
+ { input: "čÄfė 80's", expected: ["čäfė", "80", "s"] },
+ { input: "我知道很多东西。", expected: ["我知道很多东西"] },
+ ];
+ let checkTokenization = tc => {
+ it(`${tc.input} should tokenize to ${tc.expected}`, () => {
+ assert.deepEqual(tc.expected, tokenize(tc.input));
+ });
+ };
+
+ for (let i = 0; i < testCases.length; i++) {
+ checkTokenization(testCases[i]);
+ }
+ });
+
+ describe("#tfidf", () => {
+ let vocab_idfs = {
+ deal: [221, 5.5058519847862275],
+ easy: [269, 5.5058519847862275],
+ tanks: [867, 5.601162164590552],
+ sites: [792, 5.957837108529285],
+ care: [153, 5.957837108529285],
+ needs: [596, 5.824305715904762],
+ finally: [334, 5.706522680248379],
+ };
+ let testCases = [
+ {
+ input: "Finally! Easy care for your tanks!",
+ expected: {
+ finally: [334, 0.5009816295853761],
+ easy: [269, 0.48336453811728713],
+ care: [153, 0.5230447876368227],
+ tanks: [867, 0.49173191907236774],
+ },
+ },
+ {
+ input: "Easy easy EASY",
+ expected: { easy: [269, 1.0] },
+ },
+ {
+ input: "Easy easy care",
+ expected: {
+ easy: [269, 0.8795205218806832],
+ care: [153, 0.4758609582543317],
+ },
+ },
+ {
+ input: "easy care",
+ expected: {
+ easy: [269, 0.6786999710383944],
+ care: [153, 0.7344156515982504],
+ },
+ },
+ {
+ input: "这个空间故意留空。",
+ expected: {
+ /* This space is left intentionally blank. */
+ },
+ },
+ ];
+ let checkTokenGeneration = tc => {
+ describe(`${tc.input} should have only vocabulary tokens`, () => {
+ let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs);
+
+ it(`${tc.input} should generate exactly ${Object.keys(
+ tc.expected
+ )}`, () => {
+ let seen = {};
+ Object.keys(actual).forEach(actualTok => {
+ assert.isTrue(actualTok in tc.expected);
+ seen[actualTok] = true;
+ });
+ Object.keys(tc.expected).forEach(expectedTok => {
+ assert.isTrue(expectedTok in seen);
+ });
+ });
+
+ it(`${tc.input} should have the correct token ids`, () => {
+ Object.keys(actual).forEach(actualTok => {
+ assert.equal(tc.expected[actualTok][0], actual[actualTok][0]);
+ });
+ });
+ });
+ };
+
+ let checkTfIdfVector = tc => {
+ let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs);
+ it(`${tc.input} should have the correct tf-idf`, () => {
+ Object.keys(actual).forEach(actualTok => {
+ let delta = Math.abs(
+ tc.expected[actualTok][1] - actual[actualTok][1]
+ );
+ assert.isTrue(delta <= EPSILON);
+ });
+ });
+ };
+
+ // run the tests
+ for (let i = 0; i < testCases.length; i++) {
+ checkTokenGeneration(testCases[i]);
+ checkTfIdfVector(testCases[i]);
+ }
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
new file mode 100644
index 0000000000..20210ab7b1
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js
@@ -0,0 +1,1245 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import injector from "inject!lib/PlacesFeed.jsm";
+
+const FAKE_BOOKMARK = {
+ bookmarkGuid: "xi31",
+ bookmarkTitle: "Foo",
+ dateAdded: 123214232,
+ url: "foo.com",
+};
+const TYPE_BOOKMARK = 0; // This is fake, for testing
+const SOURCES = {
+ DEFAULT: 0,
+ SYNC: 1,
+ IMPORT: 2,
+ RESTORE: 5,
+ RESTORE_ON_STARTUP: 6,
+};
+
+const BLOCKED_EVENT = "newtab-linkBlocked"; // The event dispatched in NewTabUtils when a link is blocked;
+
+const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
+const POCKET_SITE_PREF = "extensions.pocket.site";
+
+describe("PlacesFeed", () => {
+ let PlacesFeed;
+ let PlacesObserver;
+ let globals;
+ let sandbox;
+ let feed;
+ let shortURLStub;
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ globals.set("NewTabUtils", {
+ activityStreamProvider: { getBookmark() {} },
+ activityStreamLinks: {
+ addBookmark: sandbox.spy(),
+ deleteBookmark: sandbox.spy(),
+ deleteHistoryEntry: sandbox.spy(),
+ blockURL: sandbox.spy(),
+ addPocketEntry: sandbox.spy(() => Promise.resolve()),
+ deletePocketEntry: sandbox.spy(() => Promise.resolve()),
+ archivePocketEntry: sandbox.spy(() => Promise.resolve()),
+ },
+ });
+ globals.set("pktApi", {
+ isUserLoggedIn: sandbox.spy(),
+ });
+ globals.set("ExperimentAPI", {
+ getExperiment: sandbox.spy(),
+ });
+ globals.set("NimbusFeatures", {
+ pocketNewtab: {
+ getVariable: sandbox.spy(),
+ },
+ });
+ globals.set("PartnerLinkAttribution", {
+ makeRequest: sandbox.spy(),
+ });
+ sandbox
+ .stub(global.PlacesUtils.bookmarks, "TYPE_BOOKMARK")
+ .value(TYPE_BOOKMARK);
+ sandbox.stub(global.PlacesUtils.bookmarks, "SOURCES").value(SOURCES);
+ sandbox.spy(global.PlacesUtils.history, "addObserver");
+ sandbox.spy(global.PlacesUtils.history, "removeObserver");
+ sandbox.spy(global.PlacesUtils.observers, "addListener");
+ sandbox.spy(global.PlacesUtils.observers, "removeListener");
+ sandbox.spy(global.Services.obs, "addObserver");
+ sandbox.spy(global.Services.obs, "removeObserver");
+ sandbox.spy(global.console, "error");
+ shortURLStub = sandbox
+ .stub()
+ .callsFake(site =>
+ site.url.replace(/(.com|.ca)/, "").replace("https://", "")
+ );
+
+ global.Services.io.newURI = spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ scheme: "https",
+ });
+
+ global.Cc["@mozilla.org/timer;1"] = {
+ createInstance() {
+ return {
+ initWithCallback: sinon.stub().callsFake(callback => callback()),
+ cancel: sinon.spy(),
+ };
+ },
+ };
+ ({ PlacesFeed } = injector({
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ }));
+ PlacesObserver = PlacesFeed.PlacesObserver;
+ feed = new PlacesFeed();
+ feed.store = { dispatch: sinon.spy() };
+ globals.set("AboutNewTab", {
+ activityStream: { store: { feeds: { get() {} } } },
+ });
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ it("should have a PlacesObserver that dispatches to the store", () => {
+ assert.instanceOf(feed.placesObserver, PlacesObserver);
+ const action = { type: "FOO" };
+
+ feed.placesObserver.dispatch(action);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(feed.store.dispatch.firstCall.args[0].type, action.type);
+ });
+
+ describe("#addToBlockedTopSitesSponsors", () => {
+ let spy;
+ beforeEach(() => {
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+ spy = sandbox.spy(global.Services.prefs, "setStringPref");
+ });
+
+ it("should add the blocked sponsors to the blocklist", () => {
+ feed.addToBlockedTopSitesSponsors([
+ { url: "test.com" },
+ { url: "test1.com" },
+ ]);
+
+ assert.calledOnce(spy);
+ const [, sponsors] = spy.firstCall.args;
+ assert.deepEqual(
+ new Set(["foo", "bar", "test", "test1"]),
+ new Set(JSON.parse(sponsors))
+ );
+ });
+
+ it("should not add duplicate sponsors to the blocklist", () => {
+ feed.addToBlockedTopSitesSponsors([
+ { url: "foo.com" },
+ { url: "bar.com" },
+ { url: "test.com" },
+ ]);
+
+ assert.calledOnce(spy);
+ const [, sponsors] = spy.firstCall.args;
+ assert.deepEqual(
+ new Set(["foo", "bar", "test"]),
+ new Set(JSON.parse(sponsors))
+ );
+ });
+ });
+
+ describe("#onAction", () => {
+ it("should add bookmark, history, places, blocked observers on INIT", () => {
+ feed.onAction({ type: at.INIT });
+
+ assert.calledWith(
+ global.PlacesUtils.observers.addListener,
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "history-cleared",
+ "page-removed",
+ ],
+ feed.placesObserver.handlePlacesEvent
+ );
+ assert.calledWith(global.Services.obs.addObserver, feed, BLOCKED_EVENT);
+ });
+ it("should remove bookmark, history, places, blocked observers, and timers on UNINIT", () => {
+ feed.placesChangedTimer =
+ global.Cc["@mozilla.org/timer;1"].createInstance();
+ let spy = feed.placesChangedTimer.cancel;
+ feed.onAction({ type: at.UNINIT });
+
+ assert.calledWith(
+ global.PlacesUtils.observers.removeListener,
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "history-cleared",
+ "page-removed",
+ ],
+ feed.placesObserver.handlePlacesEvent
+ );
+ assert.calledWith(
+ global.Services.obs.removeObserver,
+ feed,
+ BLOCKED_EVENT
+ );
+ assert.equal(feed.placesChangedTimer, null);
+ assert.calledOnce(spy);
+ });
+ it("should block a url on BLOCK_URL", () => {
+ feed.onAction({
+ type: at.BLOCK_URL,
+ data: [{ url: "apple.com", pocket_id: 1234 }],
+ });
+ assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, {
+ url: "apple.com",
+ pocket_id: 1234,
+ });
+ });
+ it("should update the blocked top sites sponsors", () => {
+ sandbox.stub(feed, "addToBlockedTopSitesSponsors");
+ feed.onAction({
+ type: at.BLOCK_URL,
+ data: [{ url: "foo.com", pocket_id: 1234, isSponsoredTopSite: 1 }],
+ });
+ assert.calledWith(feed.addToBlockedTopSitesSponsors, [
+ { url: "foo.com" },
+ ]);
+ });
+ it("should bookmark a url on BOOKMARK_URL", () => {
+ const data = { url: "pear.com", title: "A pear" };
+ const _target = { browser: { ownerGlobal() {} } };
+ feed.onAction({ type: at.BOOKMARK_URL, data, _target });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.addBookmark,
+ data,
+ _target.browser.ownerGlobal
+ );
+ });
+ it("should delete a bookmark on DELETE_BOOKMARK_BY_ID", () => {
+ feed.onAction({ type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd" });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteBookmark,
+ "g123kd"
+ );
+ });
+ it("should delete a history entry on DELETE_HISTORY_URL", () => {
+ feed.onAction({
+ type: at.DELETE_HISTORY_URL,
+ data: { url: "guava.com", forceBlock: null },
+ });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteHistoryEntry,
+ "guava.com"
+ );
+ assert.notCalled(global.NewTabUtils.activityStreamLinks.blockURL);
+ });
+ it("should delete a history entry on DELETE_HISTORY_URL and force a site to be blocked if specified", () => {
+ feed.onAction({
+ type: at.DELETE_HISTORY_URL,
+ data: { url: "guava.com", forceBlock: "g123kd" },
+ });
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deleteHistoryEntry,
+ "guava.com"
+ );
+ assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, {
+ url: "guava.com",
+ pocket_id: undefined,
+ });
+ });
+ it("should call openTrustedLinkIn with the correct url, where and params on OPEN_NEW_WINDOW", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openWindowAction = {
+ type: at.OPEN_NEW_WINDOW,
+ data: { url: "https://foo.com" },
+ _target: { browser: { ownerGlobal: { openTrustedLinkIn } } },
+ };
+
+ feed.onAction(openWindowAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "window");
+ assert.propertyVal(params, "private", false);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should call openTrustedLinkIn with the correct url, where, params and privacy args on OPEN_PRIVATE_WINDOW", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openWindowAction = {
+ type: at.OPEN_PRIVATE_WINDOW,
+ data: { url: "https://foo.com" },
+ _target: { browser: { ownerGlobal: { openTrustedLinkIn } } },
+ };
+
+ feed.onAction(openWindowAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "window");
+ assert.propertyVal(params, "private", true);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should call openTrustedLinkIn with the correct url, where and params on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: { url: "https://foo.com" },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "https://foo.com");
+ assert.equal(where, "current");
+ assert.propertyVal(params, "private", false);
+ assert.propertyVal(params, "forceForeground", false);
+ });
+ it("should open link with referrer on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const 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);
+
+ const [, , params] = openTrustedLinkIn.firstCall.args;
+ assert.nestedPropertyVal(params, "referrerInfo.referrerPolicy", 5);
+ assert.nestedPropertyVal(
+ params,
+ "referrerInfo.originalReferrer.spec",
+ "https://foo.com/ref"
+ );
+ });
+ it("should mark link with typed bonus as typed before opening OPEN_LINK", () => {
+ const callOrder = [];
+ sinon
+ .stub(global.PlacesUtils.history, "markPageAsTyped")
+ .callsFake(() => {
+ callOrder.push("markPageAsTyped");
+ });
+ const openTrustedLinkIn = sinon.stub().callsFake(() => {
+ callOrder.push("openTrustedLinkIn");
+ });
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: {
+ typedBonus: true,
+ url: "https://foo.com",
+ },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.sameOrderedMembers(callOrder, [
+ "markPageAsTyped",
+ "openTrustedLinkIn",
+ ]);
+ });
+ it("should open the pocket link if it's a pocket story on OPEN_LINK", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: {
+ url: "https://foo.com",
+ open_url: "getpocket.com/foo",
+ type: "pocket",
+ },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where, params] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url, "getpocket.com/foo");
+ assert.equal(where, "current");
+ assert.propertyVal(params, "private", false);
+ });
+ it("should not open link if not http", () => {
+ const openTrustedLinkIn = sinon.stub();
+ global.Services.io.newURI = spec => ({
+ mutate: () => ({
+ setRef: ref => ({
+ finalize: () => ({
+ ref,
+ spec,
+ }),
+ }),
+ }),
+ spec,
+ scheme: "file",
+ });
+ const openLinkAction = {
+ type: at.OPEN_LINK,
+ data: { url: "file:///foo.com" },
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+ const [e] = global.console.error.firstCall.args;
+ assert.equal(
+ e.message,
+ "Can't open link using file protocol from the new tab page."
+ );
+ });
+ it("should call fillSearchTopSiteTerm on FILL_SEARCH_TERM", () => {
+ sinon.stub(feed, "fillSearchTopSiteTerm");
+
+ feed.onAction({ type: at.FILL_SEARCH_TERM });
+
+ assert.calledOnce(feed.fillSearchTopSiteTerm);
+ });
+ it("should call openTrustedLinkIn with the correct SUMO url on ABOUT_SPONSORED_TOP_SITES", () => {
+ const openTrustedLinkIn = sinon.stub();
+ const openLinkAction = {
+ type: at.ABOUT_SPONSORED_TOP_SITES,
+ _target: {
+ browser: {
+ ownerGlobal: { openTrustedLinkIn },
+ },
+ },
+ };
+
+ feed.onAction(openLinkAction);
+
+ assert.calledOnce(openTrustedLinkIn);
+ const [url, where] = openTrustedLinkIn.firstCall.args;
+ assert.equal(url.endsWith("sponsor-privacy"), true);
+ assert.equal(where, "tab");
+ });
+ it("should set the URL bar value to the label value", async () => {
+ const locationBar = { search: sandbox.stub() };
+ const action = {
+ type: at.FILL_SEARCH_TERM,
+ data: { label: "@Foo" },
+ _target: { browser: { ownerGlobal: { gURLBar: locationBar } } },
+ };
+
+ await feed.fillSearchTopSiteTerm(action);
+
+ assert.calledOnce(locationBar.search);
+ assert.calledWithExactly(locationBar.search, "@Foo", {
+ searchEngine: null,
+ searchModeEntry: "topsites_newtab",
+ });
+ });
+ it("should call saveToPocket on SAVE_TO_POCKET", () => {
+ const action = {
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ sinon.stub(feed, "saveToPocket");
+ feed.onAction(action);
+ assert.calledWithExactly(
+ feed.saveToPocket,
+ action.data.site,
+ action._target.browser
+ );
+ });
+ it("should openTrustedLinkIn with sendToPocket if not logged in", () => {
+ const openTrustedLinkIn = sinon.stub();
+ global.NimbusFeatures.pocketNewtab.getVariable = sandbox
+ .stub()
+ .returns(true);
+ global.pktApi.isUserLoggedIn = sandbox.stub().returns(false);
+ global.ExperimentAPI.getExperiment = sandbox.stub().returns({
+ slug: "slug",
+ branch: { slug: "branch-slug" },
+ });
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(POCKET_SITE_PREF)
+ .returns("getpocket.com");
+ const action = {
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: {
+ browser: {
+ ownerGlobal: {
+ openTrustedLinkIn,
+ },
+ },
+ },
+ };
+ feed.onAction(action);
+ assert.calledOnce(openTrustedLinkIn);
+ const [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");
+ });
+ it("should call NewTabUtils.activityStreamLinks.addPocketEntry if we are saving a pocket story", async () => {
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.addPocketEntry);
+ assert.calledWithExactly(
+ global.NewTabUtils.activityStreamLinks.addPocketEntry,
+ action.data.site.url,
+ action.data.site.title,
+ action._target.browser
+ );
+ });
+ it("should reject the promise if NewTabUtils.activityStreamLinks.addPocketEntry rejects", async () => {
+ const e = new Error("Error");
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.calledWith(global.console.error, e);
+ });
+ it("should broadcast to content if we successfully added a link to Pocket", async () => {
+ // test in the form that the API returns data based on: https://getpocket.com/developer/docs/v3/add
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .resolves({ item: { open_url: "pocket.com/itemID", item_id: 1234 } });
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ 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",
+ });
+ });
+ it("should only broadcast if we got some data back from addPocketEntry", async () => {
+ global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox
+ .stub()
+ .resolves(null);
+ const action = {
+ data: { site: { url: "raspberry.com", title: "raspberry" } },
+ _target: { browser: {} },
+ };
+ await feed.saveToPocket(action.data.site, action._target.browser);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should call deleteFromPocket on DELETE_FROM_POCKET", () => {
+ sandbox.stub(feed, "deleteFromPocket");
+ feed.onAction({
+ type: at.DELETE_FROM_POCKET,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.deleteFromPocket);
+ assert.calledWithExactly(feed.deleteFromPocket, 12345);
+ });
+ it("should catch if deletePocketEntry throws", async () => {
+ const e = new Error("Error");
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.deleteFromPocket(12345);
+
+ assert.calledWith(global.console.error, e);
+ });
+ it("should call NewTabUtils.deletePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when deleting from Pocket", async () => {
+ await feed.deleteFromPocket(12345);
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry
+ );
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.deletePocketEntry,
+ 12345
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ });
+ });
+ it("should call archiveFromPocket on ARCHIVE_FROM_POCKET", async () => {
+ sandbox.stub(feed, "archiveFromPocket");
+ await feed.onAction({
+ type: at.ARCHIVE_FROM_POCKET,
+ data: { pocket_id: 12345 },
+ });
+
+ assert.calledOnce(feed.archiveFromPocket);
+ assert.calledWithExactly(feed.archiveFromPocket, 12345);
+ });
+ it("should catch if archiveFromPocket throws", async () => {
+ const e = new Error("Error");
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry = sandbox
+ .stub()
+ .rejects(e);
+ await feed.archiveFromPocket(12345);
+
+ assert.calledWith(global.console.error, e);
+ });
+ it("should call NewTabUtils.archivePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when archiving from Pocket", async () => {
+ await feed.archiveFromPocket(12345);
+
+ assert.calledOnce(
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry
+ );
+ assert.calledWith(
+ global.NewTabUtils.activityStreamLinks.archivePocketEntry,
+ 12345
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ type: at.POCKET_LINK_DELETED_OR_ARCHIVED,
+ });
+ });
+ it("should call handoffSearchToAwesomebar on HANDOFF_SEARCH_TO_AWESOMEBAR", () => {
+ const action = {
+ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR,
+ data: { text: "f" },
+ meta: { fromTarget: {} },
+ _target: { browser: { ownerGlobal: { gURLBar: { focus: () => {} } } } },
+ };
+ sinon.stub(feed, "handoffSearchToAwesomebar");
+ feed.onAction(action);
+ assert.calledWith(feed.handoffSearchToAwesomebar, action);
+ });
+ it("should call makeAttributionRequest on PARTNER_LINK_ATTRIBUTION", () => {
+ sinon.stub(feed, "makeAttributionRequest");
+ let data = { targetURL: "https://partnersite.com", source: "topsites" };
+ feed.onAction({
+ type: at.PARTNER_LINK_ATTRIBUTION,
+ data,
+ });
+
+ assert.calledOnce(feed.makeAttributionRequest);
+ assert.calledWithExactly(feed.makeAttributionRequest, data);
+ });
+ it("should call PartnerLinkAttribution.makeRequest when calling makeAttributionRequest", () => {
+ let data = { targetURL: "https://partnersite.com", source: "topsites" };
+ feed.makeAttributionRequest(data);
+ assert.calledOnce(global.PartnerLinkAttribution.makeRequest);
+ });
+ });
+
+ describe("handoffSearchToAwesomebar", () => {
+ let fakeUrlBar;
+ let listeners;
+
+ beforeEach(() => {
+ fakeUrlBar = {
+ focus: sinon.spy(),
+ handoff: sinon.spy(),
+ setHiddenFocus: sinon.spy(),
+ removeHiddenFocus: sinon.spy(),
+ addEventListener: (ev, cb) => {
+ listeners[ev] = cb;
+ },
+ removeEventListener: sinon.spy(),
+ };
+ listeners = {};
+ });
+ it("should properly handle handoff with no text passed in", () => {
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: {},
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.setHiddenFocus);
+ assert.notCalled(fakeUrlBar.handoff);
+ assert.notCalled(feed.store.dispatch);
+
+ // Now type a character.
+ listeners.keydown({ key: "f" });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledOnce(fakeUrlBar.removeHiddenFocus);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "DISABLE_SEARCH",
+ });
+ });
+ it("should properly handle handoff with text data passed in", () => {
+ const sessionId = "decafc0ffee";
+ sandbox
+ .stub(global.AboutNewTab.activityStream.store.feeds, "get")
+ .returns({
+ sessions: {
+ get: () => {
+ return { session_id: sessionId };
+ },
+ },
+ });
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultEngine,
+ sessionId
+ );
+ assert.notCalled(fakeUrlBar.focus);
+ assert.notCalled(fakeUrlBar.setHiddenFocus);
+
+ // Now call blur listener.
+ listeners.blur();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ });
+ it("should properly handle handoff with text data passed in, in private browsing mode", () => {
+ global.PrivateBrowsingUtils.isBrowserPrivate = () => true;
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultPrivateEngine,
+ undefined
+ );
+ assert.notCalled(fakeUrlBar.focus);
+ assert.notCalled(fakeUrlBar.setHiddenFocus);
+
+ // Now call blur listener.
+ listeners.blur();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ global.PrivateBrowsingUtils.isBrowserPrivate = () => false;
+ });
+ it("should SHOW_SEARCH on ESC keydown", () => {
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: { text: "foo" },
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "foo",
+ global.Services.search.defaultEngine,
+ undefined
+ );
+ assert.notCalled(fakeUrlBar.focus);
+
+ // Now call ESC keydown.
+ listeners.keydown({ key: "Escape" });
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "SHOW_SEARCH",
+ });
+ });
+ it("should properly handoff a newtab session id with no text passed in", () => {
+ const sessionId = "decafc0ffee";
+ sandbox
+ .stub(global.AboutNewTab.activityStream.store.feeds, "get")
+ .returns({
+ sessions: {
+ get: () => {
+ return { session_id: sessionId };
+ },
+ },
+ });
+ feed.handoffSearchToAwesomebar({
+ _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } },
+ data: {},
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(fakeUrlBar.setHiddenFocus);
+ assert.notCalled(fakeUrlBar.handoff);
+ assert.notCalled(feed.store.dispatch);
+
+ // Now type a character.
+ listeners.keydown({ key: "f" });
+ assert.calledOnce(fakeUrlBar.handoff);
+ assert.calledWithExactly(
+ fakeUrlBar.handoff,
+ "",
+ global.Services.search.defaultEngine,
+ sessionId
+ );
+ assert.calledOnce(fakeUrlBar.removeHiddenFocus);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ meta: {
+ from: "ActivityStream:Main",
+ skipMain: true,
+ to: "ActivityStream:Content",
+ toTarget: {},
+ },
+ type: "DISABLE_SEARCH",
+ });
+ });
+ });
+
+ describe("#observe", () => {
+ it("should dispatch a PLACES_LINK_BLOCKED action with the url of the blocked link", () => {
+ 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",
+ });
+ });
+ it("should not call dispatch if the topic is something other than BLOCKED_EVENT", () => {
+ feed.observe(null, "someotherevent");
+ assert.notCalled(feed.store.dispatch);
+ });
+ });
+
+ describe("Custom dispatch", () => {
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if many bookmark-added notifications happened at once", async () => {
+ // Yes, onItemAdded has at least 8 arguments. See function definition for docs.
+ const 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",
+ },
+ ];
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if many onItemRemoved notifications happened at once", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+ await feed.placesObserver.handlePlacesEvent(args);
+
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ it("should only dispatch 1 PLACES_LINKS_CHANGED action if any page-removed notifications happened at once", async () => {
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo.com", isRemovedFromStore: true },
+ ]);
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo1.com", isRemovedFromStore: true },
+ ]);
+ await feed.placesObserver.handlePlacesEvent([
+ { type: "page-removed", url: "foo2.com", isRemovedFromStore: true },
+ ]);
+
+ assert.calledOnce(
+ feed.store.dispatch.withArgs(
+ ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED })
+ )
+ );
+ });
+ });
+
+ describe("PlacesObserver", () => {
+ let dispatch;
+ let observer;
+ beforeEach(() => {
+ dispatch = sandbox.spy();
+ observer = new PlacesObserver(dispatch);
+ });
+
+ describe("#history-cleared", () => {
+ it("should dispatch a PLACES_HISTORY_CLEARED action", async () => {
+ const args = [{ type: "history-cleared" }];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, { type: at.PLACES_HISTORY_CLEARED });
+ });
+ });
+
+ describe("#page-removed", () => {
+ it("should dispatch a PLACES_LINKS_DELETED action with the right url", async () => {
+ const args = [
+ {
+ type: "page-removed",
+ url: "foo.com",
+ isRemovedFromStore: true,
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, {
+ type: at.PLACES_LINKS_DELETED,
+ data: { urls: ["foo.com"] },
+ });
+ });
+ });
+
+ describe("#bookmark-added", () => {
+ it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - http", async () => {
+ const 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.calledWith(dispatch.secondCall, {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid,
+ bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle,
+ dateAdded: FAKE_BOOKMARK.dateAdded * 1000,
+ url: "http://www.foo.com",
+ },
+ });
+ });
+ it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - https", async () => {
+ const 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.calledWith(dispatch.secondCall, {
+ type: at.PLACES_BOOKMARK_ADDED,
+ data: {
+ bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid,
+ bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle,
+ dateAdded: FAKE_BOOKMARK.dateAdded * 1000,
+ url: "https://www.foo.com",
+ },
+ });
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - not http/https", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has IMPORT source", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE source", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE_ON_STARTUP source", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARK_ADDED action - has SYNC source", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should ignore events that are not of TYPE_BOOKMARK", async () => {
+ const args = [
+ {
+ 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",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ });
+ describe("#bookmark-removed", () => {
+ it("should ignore events that are not of TYPE_BOOKMARK", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: "nottypebookmark",
+ url: null,
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has SYNC source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.SYNC,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has IMPORT source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.IMPORT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.RESTORE,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE_ON_STARTUP source", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.RESTORE_ON_STARTUP,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+
+ assert.notCalled(dispatch);
+ });
+ it("should dispatch a PLACES_BOOKMARKS_REMOVED action with the right URL and bookmarkGuid", async () => {
+ const args = [
+ {
+ id: null,
+ parentId: null,
+ index: null,
+ itemType: TYPE_BOOKMARK,
+ url: "foo.com",
+ guid: "123foo",
+ parentGuid: "",
+ source: SOURCES.DEFAULT,
+ type: "bookmark-removed",
+ },
+ ];
+ await observer.handlePlacesEvent(args);
+ assert.calledWith(dispatch, {
+ type: at.PLACES_BOOKMARKS_REMOVED,
+ data: { urls: ["foo.com"] },
+ });
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/PrefsFeed.test.js b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js
new file mode 100644
index 0000000000..581222b3ee
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js
@@ -0,0 +1,357 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+import { PrefsFeed } from "lib/PrefsFeed.jsm";
+
+let overrider = new GlobalOverrider();
+
+describe("PrefsFeed", () => {
+ let feed;
+ let FAKE_PREFS;
+ let sandbox;
+ let ServicesStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ FAKE_PREFS = new Map([
+ ["foo", 1],
+ ["bar", 2],
+ ["baz", { value: 1, skipBroadcast: true }],
+ ["qux", { value: 1, skipBroadcast: true, alsoToPreloaded: true }],
+ ]);
+ feed = new PrefsFeed(FAKE_PREFS);
+ const storage = {
+ getAll: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ ServicesStub = {
+ prefs: {
+ clearUserPref: sinon.spy(),
+ getStringPref: sinon.spy(),
+ getIntPref: sinon.spy(),
+ getBoolPref: sinon.spy(),
+ },
+ obs: {
+ removeObserver: sinon.spy(),
+ addObserver: sinon.spy(),
+ },
+ };
+ sinon.spy(feed, "_setPref");
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ // Setup for tests that don't call `init`
+ feed._storage = storage;
+ feed._prefs = {
+ get: sinon.spy(item => FAKE_PREFS.get(item)),
+ set: sinon.spy((name, value) => FAKE_PREFS.set(name, value)),
+ observe: sinon.spy(),
+ observeBranch: sinon.spy(),
+ ignore: sinon.spy(),
+ ignoreBranch: sinon.spy(),
+ reset: sinon.stub(),
+ _branchStr: "branch.str.",
+ };
+ overrider.set({
+ PrivateBrowsingUtils: { enabled: true },
+ Services: ServicesStub,
+ });
+ });
+ afterEach(() => {
+ overrider.restore();
+ sandbox.restore();
+ });
+
+ it("should set a pref when a SET_PREF action is received", () => {
+ feed.onAction(ac.SetPref("foo", 2));
+ assert.calledWith(feed._prefs.set, "foo", 2);
+ });
+ it("should call clearUserPref with action CLEAR_PREF", () => {
+ feed.onAction({ type: at.CLEAR_PREF, data: { name: "pref.test" } });
+ assert.calledWith(ServicesStub.prefs.clearUserPref, "branch.str.pref.test");
+ });
+ it("should dispatch PREFS_INITIAL_VALUES on init with pref values and .isPrivateBrowsingEnabled", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.equal(data.foo, 1);
+ assert.equal(data.bar, 2);
+ assert.isTrue(data.isPrivateBrowsingEnabled);
+ });
+ it("should dispatch PREFS_INITIAL_VALUES with a .featureConfig", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({
+ prefsButtonIcon: "icon-foo",
+ });
+ feed.onAction({ type: at.INIT });
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.deepEqual(data.featureConfig, { prefsButtonIcon: "icon-foo" });
+ });
+ it("should dispatch PREFS_INITIAL_VALUES with an empty object if no experiment is returned", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns(null);
+ feed.onAction({ type: at.INIT });
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].type,
+ at.PREFS_INITIAL_VALUES
+ );
+ const [{ data }] = feed.store.dispatch.firstCall.args;
+ assert.deepEqual(data.featureConfig, {});
+ });
+ it("should add one branch observer on init", () => {
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed._prefs.observeBranch);
+ assert.calledWith(feed._prefs.observeBranch, feed);
+ });
+ it("should initialise the storage on init", () => {
+ feed.init();
+
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should handle region on init", () => {
+ feed.init();
+ assert.equal(feed.geo, "US");
+ });
+ it("should add region observer on init", () => {
+ sandbox.stub(global.Region, "home").get(() => "");
+ feed.init();
+ assert.equal(feed.geo, "");
+ assert.calledWith(
+ ServicesStub.obs.addObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should remove the branch observer on uninit", () => {
+ feed.onAction({ type: at.UNINIT });
+ assert.calledOnce(feed._prefs.ignoreBranch);
+ assert.calledWith(feed._prefs.ignoreBranch, feed);
+ });
+ it("should call removeObserver", () => {
+ feed.geo = "";
+ feed.uninit();
+ assert.calledWith(
+ ServicesStub.obs.removeObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+ it("should send a PREF_CHANGED action when onPrefChanged is called", () => {
+ feed.onPrefChanged("foo", 2);
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: { name: "foo", value: 2 },
+ })
+ );
+ });
+ it("should send a PREF_CHANGED actions when onPocketExperimentUpdated is called", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables")
+ .returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onPocketExperimentUpdated();
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "pocketConfig",
+ value: {
+ prefsButtonIcon: "icon-new",
+ },
+ },
+ })
+ );
+ });
+ it("should not send a PREF_CHANGED actions when onPocketExperimentUpdated is called during startup", () => {
+ sandbox
+ .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables")
+ .returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onPocketExperimentUpdated({}, "feature-experiment-loaded");
+ assert.notCalled(feed.store.dispatch);
+ feed.onPocketExperimentUpdated({}, "feature-rollout-loaded");
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should send a PREF_CHANGED actions when onExperimentUpdated is called", () => {
+ sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({
+ prefsButtonIcon: "icon-new",
+ });
+ feed.onExperimentUpdated();
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "featureConfig",
+ value: {
+ prefsButtonIcon: "icon-new",
+ },
+ },
+ })
+ );
+ });
+
+ it("should remove all events on removeListeners", () => {
+ feed.geo = "";
+ sandbox.spy(global.NimbusFeatures.pocketNewtab, "offUpdate");
+ sandbox.spy(global.NimbusFeatures.newtab, "offUpdate");
+ feed.removeListeners();
+ assert.calledWith(
+ global.NimbusFeatures.pocketNewtab.offUpdate,
+ feed.onPocketExperimentUpdated
+ );
+ assert.calledWith(
+ global.NimbusFeatures.newtab.offUpdate,
+ feed.onExperimentUpdated
+ );
+ assert.calledWith(
+ ServicesStub.obs.removeObserver,
+ feed,
+ global.Region.REGION_TOPIC
+ );
+ });
+
+ it("should set storage pref on UPDATE_SECTION_PREFS", async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topsites", value: { collapsed: false } },
+ });
+ assert.calledWith(feed._storage.set, "topsites", { collapsed: false });
+ });
+ it("should set storage pref with section prefix on UPDATE_SECTION_PREFS", async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topstories", value: { collapsed: false } },
+ });
+ assert.calledWith(feed._storage.set, "feeds.section.topstories", {
+ collapsed: false,
+ });
+ });
+ it("should catch errors on UPDATE_SECTION_PREFS", async () => {
+ feed._storage.set.throws(new Error("foo"));
+ assert.doesNotThrow(async () => {
+ await feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topstories", value: { collapsed: false } },
+ });
+ });
+ });
+ it("should send OnlyToMain pref update if config for pref has skipBroadcast: true", async () => {
+ feed.onPrefChanged("baz", { value: 2, skipBroadcast: true });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.OnlyToMain({
+ type: at.PREF_CHANGED,
+ data: { name: "baz", value: { value: 2, skipBroadcast: true } },
+ })
+ );
+ });
+ it("should send AlsoToPreloaded pref update if config for pref has skipBroadcast: true and alsoToPreloaded: true", async () => {
+ feed.onPrefChanged("qux", {
+ value: 2,
+ skipBroadcast: true,
+ alsoToPreloaded: true,
+ });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.AlsoToPreloaded({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "qux",
+ value: { value: 2, skipBroadcast: true, alsoToPreloaded: true },
+ },
+ })
+ );
+ });
+ describe("#observe", () => {
+ it("should call dispatch from observe", () => {
+ feed.observe(undefined, global.Region.REGION_TOPIC);
+ assert.calledOnce(feed.store.dispatch);
+ });
+ });
+ describe("#_setStringPref", () => {
+ it("should call _setPref and getStringPref from _setStringPref", () => {
+ feed._setStringPref({}, "fake.pref", "default");
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ "default"
+ );
+ assert.calledOnce(ServicesStub.prefs.getStringPref);
+ assert.calledWith(
+ ServicesStub.prefs.getStringPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ "default"
+ );
+ });
+ });
+ describe("#_setBoolPref", () => {
+ it("should call _setPref and getBoolPref from _setBoolPref", () => {
+ feed._setBoolPref({}, "fake.pref", false);
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ false
+ );
+ assert.calledOnce(ServicesStub.prefs.getBoolPref);
+ assert.calledWith(
+ ServicesStub.prefs.getBoolPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ false
+ );
+ });
+ });
+ describe("#_setIntPref", () => {
+ it("should call _setPref and getIntPref from _setIntPref", () => {
+ feed._setIntPref({}, "fake.pref", 1);
+ assert.calledOnce(feed._setPref);
+ assert.calledWith(
+ feed._setPref,
+ { "fake.pref": undefined },
+ "fake.pref",
+ 1
+ );
+ assert.calledOnce(ServicesStub.prefs.getIntPref);
+ assert.calledWith(
+ ServicesStub.prefs.getIntPref,
+ "browser.newtabpage.activity-stream.fake.pref",
+ 1
+ );
+ });
+ });
+ describe("#_setPref", () => {
+ it("should set pref value with _setPref", () => {
+ const getPrefFunctionSpy = sinon.spy();
+ const values = {};
+ feed._setPref(values, "fake.pref", "default", getPrefFunctionSpy);
+ assert.deepEqual(values, { "fake.pref": undefined });
+ assert.calledOnce(getPrefFunctionSpy);
+ assert.calledWith(
+ getPrefFunctionSpy,
+ "browser.newtabpage.activity-stream.fake.pref",
+ "default"
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js
new file mode 100644
index 0000000000..3ddbf182c3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js
@@ -0,0 +1,162 @@
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { RecommendationProvider } from "lib/RecommendationProvider.jsm";
+import { combineReducers, createStore } from "redux";
+import { reducers } from "common/Reducers.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm";
+
+const PREF_PERSONALIZATION_ENABLED = "discoverystream.personalization.enabled";
+const PREF_PERSONALIZATION_MODEL_KEYS =
+ "discoverystream.personalization.modelKeys";
+describe("RecommendationProvider", () => {
+ let feed;
+ let sandbox;
+ let globals;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ globals.set({
+ PersonalityProvider,
+ });
+
+ sandbox = sinon.createSandbox();
+ feed = new RecommendationProvider();
+ feed.store = createStore(combineReducers(reducers), {});
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+
+ describe("#setProvider", () => {
+ it("should setup proper provider with modelKeys", async () => {
+ feed.setProvider();
+
+ assert.equal(feed.provider.modelKeys, undefined);
+
+ feed.provider = null;
+ feed._modelKeys = "1234";
+
+ feed.setProvider();
+
+ assert.equal(feed.provider.modelKeys, "1234");
+ feed._modelKeys = "12345";
+
+ // Calling it again should not rebuild the provider.
+ feed.setProvider();
+ assert.equal(feed.provider.modelKeys, "1234");
+ });
+ });
+
+ describe("#init", () => {
+ it("should init affinityProvider then refreshContent", async () => {
+ feed.provider = {
+ init: sandbox.stub().resolves(),
+ };
+ await feed.init();
+ assert.calledOnce(feed.provider.init);
+ });
+ });
+
+ describe("#getScores", () => {
+ it("should call affinityProvider.getScores", () => {
+ feed.provider = {
+ getScores: sandbox.stub().resolves(),
+ };
+ feed.getScores();
+ assert.calledOnce(feed.provider.getScores);
+ });
+ });
+
+ describe("#calculateItemRelevanceScore", () => {
+ it("should use personalized score with provider", async () => {
+ const item = {};
+ feed.provider = {
+ calculateItemRelevanceScore: async () => 0.5,
+ };
+ await feed.calculateItemRelevanceScore(item);
+ assert.equal(item.score, 0.5);
+ });
+ });
+
+ describe("#teardown", () => {
+ it("should call provider.teardown ", () => {
+ feed.provider = {
+ teardown: sandbox.stub().resolves(),
+ };
+ feed.teardown();
+ assert.calledOnce(feed.provider.teardown);
+ });
+ });
+
+ describe("#resetState", () => {
+ it("should null affinityProviderV2 and affinityProvider", () => {
+ feed._modelKeys = {};
+ feed.provider = {};
+
+ feed.resetState();
+
+ assert.equal(feed._modelKeys, null);
+ assert.equal(feed.provider, null);
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
+ it("should call teardown, resetState, and setVersion", async () => {
+ sandbox.spy(feed, "teardown");
+ sandbox.spy(feed, "resetState");
+ feed.onAction({
+ type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
+ });
+ assert.calledOnce(feed.teardown);
+ assert.calledOnce(feed.resetState);
+ });
+ });
+
+ describe("#onAction: PREF_CHANGED", () => {
+ beforeEach(() => {
+ sandbox.spy(feed.store, "dispatch");
+ });
+ it("should dispatch to DISCOVERY_STREAM_CONFIG_RESET PREF_PERSONALIZATION_MODEL_KEYS", async () => {
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: PREF_PERSONALIZATION_MODEL_KEYS,
+ },
+ });
+
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET,
+ })
+ );
+ });
+ });
+
+ describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", () => {
+ it("should fire SET_PREF with enabled", async () => {
+ sandbox.spy(feed.store, "dispatch");
+ feed.store.getState = () => ({
+ Prefs: {
+ values: {
+ [PREF_PERSONALIZATION_ENABLED]: false,
+ },
+ },
+ });
+
+ await feed.onAction({
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE,
+ });
+ assert.calledWith(
+ feed.store.dispatch,
+ ac.SetPref(PREF_PERSONALIZATION_ENABLED, true)
+ );
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/Screenshots.test.js b/browser/components/newtab/test/unit/lib/Screenshots.test.js
new file mode 100644
index 0000000000..272c7ff7d3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/Screenshots.test.js
@@ -0,0 +1,209 @@
+"use strict";
+import { GlobalOverrider } from "test/unit/utils";
+import { Screenshots } from "lib/Screenshots.jsm";
+
+const URL = "foo.com";
+const FAKE_THUMBNAIL_PATH = "fake/path/thumb.jpg";
+const FAKE_THUMBNAIL_THUMB =
+ "moz-page-thumb://thumbnail?url=http%3A%2F%2Ffoo.com%2F";
+
+describe("Screenshots", () => {
+ let globals;
+ let sandbox;
+ let fakeServices;
+ let testFile;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeServices = {
+ wm: {
+ getEnumerator() {
+ return Array(10);
+ },
+ },
+ };
+ globals.set("BackgroundPageThumbs", {
+ captureIfMissing: sandbox.spy(() => Promise.resolve()),
+ });
+ globals.set("PageThumbs", {
+ _store: sandbox.stub(),
+ getThumbnailPath: sandbox.spy(() => FAKE_THUMBNAIL_PATH),
+ getThumbnailURL: sandbox.spy(() => FAKE_THUMBNAIL_THUMB),
+ });
+ globals.set("PrivateBrowsingUtils", {
+ isWindowPrivate: sandbox.spy(() => false),
+ });
+ testFile = { size: 1 };
+ globals.set("Services", fakeServices);
+ globals.set(
+ "fetch",
+ sandbox.spy(() =>
+ Promise.resolve({ blob: () => Promise.resolve(testFile) })
+ )
+ );
+ });
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#getScreenshotForURL", () => {
+ it("should call BackgroundPageThumbs.captureIfMissing with the correct url", async () => {
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.BackgroundPageThumbs.captureIfMissing, URL);
+ });
+ it("should call PageThumbs.getThumbnailPath with the correct url", async () => {
+ globals.set("gPrivilegedAboutProcessEnabled", false);
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.PageThumbs.getThumbnailPath, URL);
+ });
+ it("should call fetch", async () => {
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledOnce(global.fetch);
+ });
+ it("should have the necessary keys in the response object", async () => {
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.notEqual(screenshot.path, undefined);
+ assert.notEqual(screenshot.data, undefined);
+ });
+ it("should get null if something goes wrong", async () => {
+ globals.set("BackgroundPageThumbs", {
+ captureIfMissing: () =>
+ Promise.reject(new Error("Cannot capture thumbnail")),
+ });
+
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.calledOnce(global.PageThumbs._store);
+ assert.equal(screenshot, null);
+ });
+ it("should get direct thumbnail url for privileged process", async () => {
+ globals.set("gPrivilegedAboutProcessEnabled", true);
+ await Screenshots.getScreenshotForURL(URL);
+ assert.calledWith(global.PageThumbs.getThumbnailURL, URL);
+ });
+ it("should get null without storing if existing thumbnail is empty", async () => {
+ testFile.size = 0;
+
+ const screenshot = await Screenshots.getScreenshotForURL(URL);
+
+ assert.notCalled(global.PageThumbs._store);
+ assert.equal(screenshot, null);
+ });
+ });
+
+ describe("#maybeCacheScreenshot", () => {
+ let link;
+ beforeEach(() => {
+ link = {
+ __sharedCache: {
+ updateLink: (prop, val) => {
+ link[prop] = val;
+ },
+ },
+ };
+ });
+ it("should call getScreenshotForURL", () => {
+ sandbox.stub(Screenshots, "getScreenshotForURL");
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should not call getScreenshotForURL twice if a fetch is in progress", () => {
+ sandbox
+ .stub(Screenshots, "getScreenshotForURL")
+ .returns(new Promise(() => {}));
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+ Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.org",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should not call getScreenshotsForURL if property !== undefined", async () => {
+ sandbox
+ .stub(Screenshots, "getScreenshotForURL")
+ .returns(Promise.resolve(null));
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.org",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots.getScreenshotForURL);
+ assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com");
+ });
+ it("should check if we are in private browsing before getting screenshots", async () => {
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.calledOnce(Screenshots._shouldGetScreenshots);
+ });
+ it("should not get a screenshot if we are in private browsing", async () => {
+ sandbox.stub(Screenshots, "getScreenshotForURL");
+ sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(false);
+ await Screenshots.maybeCacheScreenshot(
+ link,
+ "mozilla.com",
+ "image",
+ sinon.stub()
+ );
+
+ assert.notCalled(Screenshots.getScreenshotForURL);
+ });
+ });
+
+ describe("#_shouldGetScreenshots", () => {
+ beforeEach(() => {
+ let more = 2;
+ sandbox
+ .stub(global.Services.wm, "getEnumerator")
+ .callsFake(() => Array(Math.max(more--, 0)));
+ });
+ it("should use private browsing utils to determine if a window is private", () => {
+ Screenshots._shouldGetScreenshots();
+ assert.calledOnce(global.PrivateBrowsingUtils.isWindowPrivate);
+ });
+ it("should return true if there exists at least 1 non-private window", () => {
+ assert.isTrue(Screenshots._shouldGetScreenshots());
+ });
+ it("should return false if there exists private windows", () => {
+ global.PrivateBrowsingUtils = {
+ isWindowPrivate: sandbox.spy(() => true),
+ };
+ assert.isFalse(Screenshots._shouldGetScreenshots());
+ assert.calledTwice(global.PrivateBrowsingUtils.isWindowPrivate);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SectionsManager.test.js b/browser/components/newtab/test/unit/lib/SectionsManager.test.js
new file mode 100644
index 0000000000..dc0be33180
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SectionsManager.test.js
@@ -0,0 +1,897 @@
+"use strict";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ CONTENT_MESSAGE_TYPE,
+ MAIN_MESSAGE_TYPE,
+ PRELOAD_MESSAGE_TYPE,
+} from "common/Actions.sys.mjs";
+import { EventEmitter, GlobalOverrider } from "test/unit/utils";
+import { SectionsFeed, SectionsManager } from "lib/SectionsManager.jsm";
+
+const FAKE_ID = "FAKE_ID";
+const FAKE_OPTIONS = { icon: "FAKE_ICON", title: "FAKE_TITLE" };
+const FAKE_ROWS = [
+ { url: "1.example.com", type: "bookmark" },
+ { url: "2.example.com", type: "pocket" },
+ { url: "3.example.com", type: "history" },
+];
+const FAKE_TRENDING_ROWS = [{ url: "bar", type: "trending" }];
+const FAKE_URL = "2.example.com";
+const FAKE_CARD_OPTIONS = { title: "Some fake title" };
+
+describe("SectionsManager", () => {
+ let globals;
+ let fakeServices;
+ let fakePlacesUtils;
+ let sandbox;
+ let storage;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ fakeServices = {
+ prefs: {
+ getBoolPref: sandbox.stub(),
+ addObserver: sandbox.stub(),
+ removeObserver: sandbox.stub(),
+ },
+ };
+ fakePlacesUtils = {
+ history: { update: sinon.stub(), insert: sinon.stub() },
+ };
+ globals.set({
+ Services: fakeServices,
+ PlacesUtils: fakePlacesUtils,
+ NimbusFeatures: {
+ newtab: { getAllVariables: sandbox.stub() },
+ pocketNewtab: { getAllVariables: sandbox.stub() },
+ },
+ });
+ // Redecorate SectionsManager to remove any listeners that have been added
+ EventEmitter.decorate(SectionsManager);
+ storage = {
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ });
+
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ describe("#init", () => {
+ it("should initialise the sections map with the built in sections", async () => {
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ await SectionsManager.init({}, storage);
+ assert.equal(SectionsManager.sections.size, 2);
+ assert.ok(SectionsManager.sections.has("topstories"));
+ assert.ok(SectionsManager.sections.has("highlights"));
+ });
+ it("should set .initialized to true", async () => {
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ await SectionsManager.init({}, storage);
+ assert.ok(SectionsManager.initialized);
+ });
+ it("should add observer for context menu prefs", async () => {
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ await SectionsManager.init({}, storage);
+ assert.calledOnce(fakeServices.prefs.addObserver);
+ assert.calledWith(
+ fakeServices.prefs.addObserver,
+ "MENU_ITEM_PREF",
+ SectionsManager
+ );
+ });
+ it("should save the reference to `storage` passed in", async () => {
+ await SectionsManager.init({}, storage);
+
+ assert.equal(SectionsManager._storage, storage);
+ });
+ });
+ describe("#uninit", () => {
+ it("should remove observer for context menu prefs", () => {
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ SectionsManager.initialized = true;
+ SectionsManager.uninit();
+ assert.calledOnce(fakeServices.prefs.removeObserver);
+ assert.calledWith(
+ fakeServices.prefs.removeObserver,
+ "MENU_ITEM_PREF",
+ SectionsManager
+ );
+ assert.isFalse(SectionsManager.initialized);
+ });
+ });
+ describe("#addBuiltInSection", () => {
+ it("should not report an error if options is undefined", async () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve());
+ await SectionsManager.addBuiltInSection(
+ "feeds.section.topstories",
+ undefined
+ );
+
+ assert.notCalled(console.error);
+ });
+ it("should report an error if options is malformed", async () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve());
+ await SectionsManager.addBuiltInSection(
+ "feeds.section.topstories",
+ "invalid"
+ );
+
+ assert.calledOnce(console.error);
+ });
+ it("should not throw if the indexedDB operation fails", async () => {
+ globals.sandbox.spy(global.console, "error");
+ storage.get = sandbox.stub().throws();
+ SectionsManager._storage = storage;
+
+ try {
+ await SectionsManager.addBuiltInSection("feeds.section.topstories");
+ } catch (e) {
+ assert.fail();
+ }
+
+ assert.calledOnce(storage.get);
+ assert.calledOnce(console.error);
+ });
+ });
+ describe("#updateSectionPrefs", () => {
+ it("should update the collapsed value of the section", async () => {
+ sandbox.stub(SectionsManager, "updateSection");
+ let topstories = SectionsManager.sections.get("topstories");
+ assert.isFalse(topstories.pref.collapsed);
+
+ await SectionsManager.updateSectionPrefs("topstories", {
+ collapsed: true,
+ });
+ topstories = SectionsManager.sections.get("topstories");
+
+ assert.isTrue(SectionsManager.updateSection.args[0][1].pref.collapsed);
+ });
+ it("should ignore invalid ids", async () => {
+ sandbox.stub(SectionsManager, "updateSection");
+ await SectionsManager.updateSectionPrefs("foo", { collapsed: true });
+
+ assert.notCalled(SectionsManager.updateSection);
+ });
+ });
+ describe("#addSection", () => {
+ it("should add the id to sections and emit an ADD_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.ADD_SECTION, spy);
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ assert.ok(SectionsManager.sections.has(FAKE_ID));
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.ADD_SECTION,
+ FAKE_ID,
+ FAKE_OPTIONS
+ );
+ });
+ });
+ describe("#removeSection", () => {
+ it("should remove the id from sections and emit an REMOVE_SECTION event", () => {
+ // Ensure we start with the id in the set
+ assert.ok(SectionsManager.sections.has(FAKE_ID));
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.REMOVE_SECTION, spy);
+ SectionsManager.removeSection(FAKE_ID);
+ assert.notOk(SectionsManager.sections.has(FAKE_ID));
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#enableSection", () => {
+ it("should call updateSection with {enabled: true}", () => {
+ sinon.spy(SectionsManager, "updateSection");
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ SectionsManager.enableSection(FAKE_ID);
+ assert.calledOnce(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ FAKE_ID,
+ { enabled: true },
+ true
+ );
+ SectionsManager.updateSection.restore();
+ });
+ it("should emit an ENABLE_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.ENABLE_SECTION, spy);
+ SectionsManager.enableSection(FAKE_ID);
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.ENABLE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#disableSection", () => {
+ it("should call updateSection with {enabled: false, rows: [], initialized: false}", () => {
+ sinon.spy(SectionsManager, "updateSection");
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ SectionsManager.disableSection(FAKE_ID);
+ assert.calledOnce(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ FAKE_ID,
+ { enabled: false, rows: [], initialized: false },
+ true
+ );
+ SectionsManager.updateSection.restore();
+ });
+ it("should emit a DISABLE_SECTION event", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.DISABLE_SECTION, spy);
+ SectionsManager.disableSection(FAKE_ID);
+ assert.calledOnce(spy);
+ assert.calledWith(spy, SectionsManager.DISABLE_SECTION, FAKE_ID);
+ });
+ });
+ describe("#updateSection", () => {
+ it("should emit an UPDATE_SECTION event with correct arguments", () => {
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ const spy = sinon.spy();
+ const dedupeConfigurations = [
+ { id: "topstories", dedupeFrom: ["highlights"] },
+ ];
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true);
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.UPDATE_SECTION,
+ FAKE_ID,
+ { rows: FAKE_ROWS, dedupeConfigurations },
+ true
+ );
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true);
+ assert.notCalled(spy);
+ });
+ it("should update all sections", () => {
+ SectionsManager.sections.clear();
+ const updateSectionOrig = SectionsManager.updateSection;
+ SectionsManager.updateSection = sinon.spy();
+
+ SectionsManager.addSection("ID1", { title: "FAKE_TITLE_1" });
+ SectionsManager.addSection("ID2", { title: "FAKE_TITLE_2" });
+ SectionsManager.updateSections();
+
+ assert.calledTwice(SectionsManager.updateSection);
+ assert.calledWith(
+ SectionsManager.updateSection,
+ "ID1",
+ { title: "FAKE_TITLE_1" },
+ true
+ );
+ assert.calledWith(
+ SectionsManager.updateSection,
+ "ID2",
+ { title: "FAKE_TITLE_2" },
+ true
+ );
+ SectionsManager.updateSection = updateSectionOrig;
+ });
+ it("context menu pref change should update sections", async () => {
+ let observer;
+ const services = {
+ prefs: {
+ getBoolPref: sinon.spy(),
+ addObserver: (pref, o) => (observer = o),
+ removeObserver: sinon.spy(),
+ },
+ };
+ globals.set("Services", services);
+
+ SectionsManager.updateSections = sinon.spy();
+ SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" };
+ await SectionsManager.init({}, storage);
+ observer.observe("", "nsPref:changed", "MENU_ITEM_PREF");
+
+ assert.calledOnce(SectionsManager.updateSections);
+ });
+ });
+ describe("#_addCardTypeLinkMenuOptions", () => {
+ const addCardTypeLinkMenuOptionsOrig =
+ SectionsManager._addCardTypeLinkMenuOptions;
+ const contextMenuOptionsOrig =
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES;
+ beforeEach(() => {
+ // Add a topstories section and a highlights section, with types for each card
+ SectionsManager.addSection("topstories", { FAKE_TRENDING_ROWS });
+ SectionsManager.addSection("highlights", { FAKE_ROWS });
+ });
+ it("should only call _addCardTypeLinkMenuOptions if the section update is for highlights", () => {
+ SectionsManager._addCardTypeLinkMenuOptions = sinon.spy();
+ SectionsManager.updateSection("topstories", { rows: FAKE_ROWS }, false);
+ assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions);
+
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ assert.calledWith(SectionsManager._addCardTypeLinkMenuOptions, FAKE_ROWS);
+ });
+ it("should only call _addCardTypeLinkMenuOptions if the section update has rows", () => {
+ SectionsManager._addCardTypeLinkMenuOptions = sinon.spy();
+ SectionsManager.updateSection("highlights", {}, false);
+ assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions);
+ });
+ it("should assign the correct context menu options based on the type of highlight", () => {
+ SectionsManager._addCardTypeLinkMenuOptions =
+ addCardTypeLinkMenuOptionsOrig;
+
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS;
+
+ // FAKE_ROWS was added in the following order: bookmark, pocket, history
+ assert.deepEqual(
+ highlights[0].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.bookmark
+ );
+ assert.deepEqual(
+ highlights[1].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.pocket
+ );
+ assert.deepEqual(
+ highlights[2].contextMenuOptions,
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.history
+ );
+ });
+ it("should throw an error if you are assigning a context menu to a non-existant highlight type", () => {
+ globals.sandbox.spy(global.console, "error");
+ SectionsManager.updateSection(
+ "highlights",
+ { rows: [{ url: "foo", type: "badtype" }] },
+ false
+ );
+ const highlights = SectionsManager.sections.get("highlights").rows;
+ assert.calledOnce(console.error);
+ assert.equal(highlights[0].contextMenuOptions, undefined);
+ });
+ it("should filter out context menu options that are in CONTEXT_MENU_PREFS", () => {
+ const services = {
+ prefs: {
+ getBoolPref: o =>
+ SectionsManager.CONTEXT_MENU_PREFS[o] !== "RemoveMe",
+ addObserver() {},
+ removeObserver() {},
+ },
+ };
+ globals.set("Services", services);
+ SectionsManager.CONTEXT_MENU_PREFS = { RemoveMe: "RemoveMe" };
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES = {
+ bookmark: ["KeepMe", "RemoveMe"],
+ pocket: ["KeepMe", "RemoveMe"],
+ history: ["KeepMe", "RemoveMe"],
+ };
+ SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false);
+ const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS;
+
+ // Only keep context menu options that were not supposed to be removed based on CONTEXT_MENU_PREFS
+ assert.deepEqual(highlights[0].contextMenuOptions, ["KeepMe"]);
+ assert.deepEqual(highlights[1].contextMenuOptions, ["KeepMe"]);
+ assert.deepEqual(highlights[2].contextMenuOptions, ["KeepMe"]);
+ SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES =
+ contextMenuOptionsOrig;
+ globals.restore();
+ });
+ });
+ describe("#onceInitialized", () => {
+ it("should call the callback immediately if SectionsManager is initialised", () => {
+ SectionsManager.initialized = true;
+ const callback = sinon.spy();
+ SectionsManager.onceInitialized(callback);
+ assert.calledOnce(callback);
+ });
+ it("should bind the callback to .once(INIT) if SectionsManager is not initialised", () => {
+ SectionsManager.initialized = false;
+ sinon.spy(SectionsManager, "once");
+ const callback = () => {};
+ SectionsManager.onceInitialized(callback);
+ assert.calledOnce(SectionsManager.once);
+ assert.calledWith(SectionsManager.once, SectionsManager.INIT, callback);
+ });
+ });
+ describe("#updateSectionCard", () => {
+ it("should emit an UPDATE_SECTION_CARD event with correct arguments", () => {
+ SectionsManager.addSection(
+ FAKE_ID,
+ Object.assign({}, FAKE_OPTIONS, { rows: FAKE_ROWS })
+ );
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy);
+ SectionsManager.updateSectionCard(
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ assert.calledOnce(spy);
+ assert.calledWith(
+ spy,
+ SectionsManager.UPDATE_SECTION_CARD,
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy);
+ SectionsManager.updateSectionCard(
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ assert.notCalled(spy);
+ });
+ });
+ describe("#removeSectionCard", () => {
+ it("should dispatch an SECTION_UPDATE action in which cards corresponding to the given url are removed", () => {
+ const rows = [{ url: "foo.com" }, { url: "bar.com" }];
+
+ SectionsManager.addSection(
+ FAKE_ID,
+ Object.assign({}, FAKE_OPTIONS, { rows })
+ );
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.removeSectionCard(FAKE_ID, "foo.com");
+
+ assert.calledOnce(spy);
+ assert.equal(spy.firstCall.args[1], FAKE_ID);
+ assert.deepEqual(spy.firstCall.args[2].rows, [{ url: "bar.com" }]);
+ });
+ it("should do nothing if the section doesn't exist", () => {
+ SectionsManager.removeSection(FAKE_ID);
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
+ SectionsManager.removeSectionCard(FAKE_ID, "bar.com");
+ assert.notCalled(spy);
+ });
+ });
+ describe("#updateBookmarkMetadata", () => {
+ beforeEach(() => {
+ let rows = [
+ {
+ url: "bar",
+ title: "title",
+ description: "description",
+ image: "image",
+ type: "trending",
+ },
+ ];
+ SectionsManager.addSection("topstories", { rows });
+ // Simulate 2 sections.
+ rows = [
+ {
+ url: "foo",
+ title: "title",
+ description: "description",
+ image: "image",
+ type: "bookmark",
+ },
+ ];
+ SectionsManager.addSection("highlights", { rows });
+ });
+
+ it("shouldn't call PlacesUtils if URL is not in topstories", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "foo" });
+
+ assert.notCalled(fakePlacesUtils.history.update);
+ });
+ it("should call PlacesUtils.history.update", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "bar" });
+
+ assert.calledOnce(fakePlacesUtils.history.update);
+ assert.calledWithExactly(fakePlacesUtils.history.update, {
+ url: "bar",
+ title: "title",
+ description: "description",
+ previewImageURL: "image",
+ });
+ });
+ it("should call PlacesUtils.history.insert", () => {
+ SectionsManager.updateBookmarkMetadata({ url: "bar" });
+
+ assert.calledOnce(fakePlacesUtils.history.insert);
+ assert.calledWithExactly(fakePlacesUtils.history.insert, {
+ url: "bar",
+ title: "title",
+ visits: [{}],
+ });
+ });
+ });
+});
+
+describe("SectionsFeed", () => {
+ let feed;
+ let sandbox;
+ let storage;
+ let globals;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ SectionsManager.sections.clear();
+ SectionsManager.initialized = false;
+ globals = new GlobalOverrider();
+ globals.set("NimbusFeatures", {
+ newtab: { getAllVariables: sandbox.stub() },
+ pocketNewtab: { getAllVariables: sandbox.stub() },
+ });
+ storage = {
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ feed = new SectionsFeed();
+ feed.store = { dispatch: sinon.spy() };
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: {
+ values: {
+ sectionOrder: "topsites,topstories,highlights",
+ "feeds.topsites": true,
+ },
+ },
+ Sections: [{ initialized: false }],
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ });
+ afterEach(() => {
+ feed.uninit();
+ globals.restore();
+ });
+ describe("#init", () => {
+ it("should create a SectionsFeed", () => {
+ assert.instanceOf(feed, SectionsFeed);
+ });
+ it("should bind appropriate listeners", () => {
+ sinon.spy(SectionsManager, "on");
+ feed.init();
+ assert.callCount(SectionsManager.on, 4);
+ for (const [event, listener] of [
+ [SectionsManager.ADD_SECTION, feed.onAddSection],
+ [SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
+ [SectionsManager.UPDATE_SECTION, feed.onUpdateSection],
+ [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard],
+ ]) {
+ assert.calledWith(SectionsManager.on, event, listener);
+ }
+ });
+ it("should call onAddSection for any already added sections in SectionsManager", async () => {
+ await SectionsManager.init({}, storage);
+ assert.ok(SectionsManager.sections.has("topstories"));
+ assert.ok(SectionsManager.sections.has("highlights"));
+ const topstories = SectionsManager.sections.get("topstories");
+ const highlights = SectionsManager.sections.get("highlights");
+ sinon.spy(feed, "onAddSection");
+ feed.init();
+ assert.calledTwice(feed.onAddSection);
+ assert.calledWith(
+ feed.onAddSection,
+ SectionsManager.ADD_SECTION,
+ "topstories",
+ topstories
+ );
+ assert.calledWith(
+ feed.onAddSection,
+ SectionsManager.ADD_SECTION,
+ "highlights",
+ highlights
+ );
+ });
+ });
+ describe("#uninit", () => {
+ it("should unbind all listeners", () => {
+ sinon.spy(SectionsManager, "off");
+ feed.init();
+ feed.uninit();
+ assert.callCount(SectionsManager.off, 4);
+ for (const [event, listener] of [
+ [SectionsManager.ADD_SECTION, feed.onAddSection],
+ [SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
+ [SectionsManager.UPDATE_SECTION, feed.onUpdateSection],
+ [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard],
+ ]) {
+ assert.calledWith(SectionsManager.off, event, listener);
+ }
+ });
+ it("should emit an UNINIT event and set SectionsManager.initialized to false", () => {
+ const spy = sinon.spy();
+ SectionsManager.on(SectionsManager.UNINIT, spy);
+ feed.init();
+ feed.uninit();
+ assert.calledOnce(spy);
+ assert.notOk(SectionsManager.initialized);
+ });
+ });
+ describe("#onAddSection", () => {
+ it("should broadcast a SECTION_REGISTER action with the correct data", () => {
+ feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_REGISTER");
+ assert.deepEqual(
+ action.data,
+ Object.assign({ id: FAKE_ID }, FAKE_OPTIONS)
+ );
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ it("should prepend id to sectionOrder pref if not already included", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS);
+ assert.calledWith(feed.store.dispatch, {
+ data: {
+ name: "sectionOrder",
+ value: `${FAKE_ID},topsites,topstories,highlights`,
+ },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+ describe("#onRemoveSection", () => {
+ it("should broadcast a SECTION_DEREGISTER action with the correct data", () => {
+ feed.onRemoveSection(null, FAKE_ID);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_DEREGISTER");
+ assert.deepEqual(action.data, FAKE_ID);
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onUpdateSection", () => {
+ it("should do nothing if no options are provided", () => {
+ feed.onUpdateSection(null, FAKE_ID, null);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should dispatch a SECTION_UPDATE action with the correct data", () => {
+ feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS });
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_UPDATE");
+ assert.deepEqual(action.data, { id: FAKE_ID, rows: FAKE_ROWS });
+ // Should be not broadcast by default, but should update the preloaded tab, so check meta
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE);
+ });
+ it("should broadcast the action only if shouldBroadcast is true", () => {
+ feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS }, true);
+ const [action] = feed.store.dispatch.firstCall.args;
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onUpdateSectionCard", () => {
+ it("should do nothing if no options are provided", () => {
+ feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, null);
+ assert.notCalled(feed.store.dispatch);
+ });
+ it("should dispatch a SECTION_UPDATE_CARD action with the correct data", () => {
+ feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, FAKE_CARD_OPTIONS);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_UPDATE_CARD");
+ assert.deepEqual(action.data, {
+ id: FAKE_ID,
+ url: FAKE_URL,
+ options: FAKE_CARD_OPTIONS,
+ });
+ // Should be not broadcast by default, but should update the preloaded tab, so check meta
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE);
+ });
+ it("should broadcast the action only if shouldBroadcast is true", () => {
+ feed.onUpdateSectionCard(
+ null,
+ FAKE_ID,
+ FAKE_URL,
+ FAKE_CARD_OPTIONS,
+ true
+ );
+ const [action] = feed.store.dispatch.firstCall.args;
+ // Should be broadcast
+ assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
+ assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
+ });
+ });
+ describe("#onAction", () => {
+ it("should bind this.init to SectionsManager.INIT on INIT", () => {
+ sinon.spy(SectionsManager, "once");
+ feed.onAction({ type: "INIT" });
+ assert.calledOnce(SectionsManager.once);
+ assert.calledWith(SectionsManager.once, SectionsManager.INIT, feed.init);
+ });
+ it("should call SectionsManager.init on action PREFS_INITIAL_VALUES", () => {
+ sinon.spy(SectionsManager, "init");
+ feed.onAction({ type: "PREFS_INITIAL_VALUES", data: { foo: "bar" } });
+ assert.calledOnce(SectionsManager.init);
+ assert.calledWith(SectionsManager.init, { foo: "bar" });
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should call SectionsManager.addBuiltInSection on suitable PREF_CHANGED events", () => {
+ sinon.spy(SectionsManager, "addBuiltInSection");
+ feed.onAction({
+ type: "PREF_CHANGED",
+ data: { name: "feeds.section.topstories.options", value: "foo" },
+ });
+ assert.calledOnce(SectionsManager.addBuiltInSection);
+ assert.calledWith(
+ SectionsManager.addBuiltInSection,
+ "feeds.section.topstories",
+ "foo"
+ );
+ });
+ it("should fire SECTION_OPTIONS_UPDATED on suitable PREF_CHANGED events", async () => {
+ await feed.onAction({
+ type: "PREF_CHANGED",
+ data: { name: "feeds.section.topstories.options", value: "foo" },
+ });
+ assert.calledOnce(feed.store.dispatch);
+ const [action] = feed.store.dispatch.firstCall.args;
+ assert.equal(action.type, "SECTION_OPTIONS_CHANGED");
+ assert.equal(action.data, "topstories");
+ });
+ it("should call SectionsManager.disableSection on SECTION_DISABLE", () => {
+ sinon.spy(SectionsManager, "disableSection");
+ feed.onAction({ type: "SECTION_DISABLE", data: 1234 });
+ assert.calledOnce(SectionsManager.disableSection);
+ assert.calledWith(SectionsManager.disableSection, 1234);
+ SectionsManager.disableSection.restore();
+ });
+ it("should call SectionsManager.enableSection on SECTION_ENABLE", () => {
+ sinon.spy(SectionsManager, "enableSection");
+ feed.onAction({ type: "SECTION_ENABLE", data: 1234 });
+ assert.calledOnce(SectionsManager.enableSection);
+ assert.calledWith(SectionsManager.enableSection, 1234);
+ SectionsManager.enableSection.restore();
+ });
+ it("should call the feed's uninit on UNINIT", () => {
+ sinon.stub(feed, "uninit");
+
+ feed.onAction({ type: "UNINIT" });
+
+ assert.calledOnce(feed.uninit);
+ });
+ it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => {
+ const spy = sinon.spy();
+ const allowedActions = SectionsManager.ACTIONS_TO_PROXY;
+ const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"];
+ feed.init();
+ SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy);
+ // Make sure we start with no sections - no event should be emitted
+ SectionsManager.sections.clear();
+ feed.onAction({ type: allowedActions[0] });
+ assert.notCalled(spy);
+ // Then add a section and check correct behaviour
+ SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
+ for (const action of allowedActions.concat(disallowedActions)) {
+ feed.onAction({ type: action });
+ }
+ for (const action of allowedActions) {
+ assert.calledWith(spy, "ACTION_DISPATCHED", action);
+ }
+ for (const action of disallowedActions) {
+ assert.neverCalledWith(spy, "ACTION_DISPATCHED", action);
+ }
+ });
+ it("should call updateBookmarkMetadata on PLACES_BOOKMARK_ADDED", () => {
+ const stub = sinon.stub(SectionsManager, "updateBookmarkMetadata");
+
+ feed.onAction({ type: "PLACES_BOOKMARK_ADDED", data: {} });
+
+ assert.calledOnce(stub);
+ });
+ it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => {
+ const stub = sinon.stub(SectionsManager, "updateSectionPrefs");
+
+ feed.onAction({ type: "UPDATE_SECTION_PREFS", data: {} });
+
+ assert.calledOnce(stub);
+ });
+ it("should call SectionManager.removeSectionCard on WEBEXT_DISMISS", () => {
+ const stub = sinon.stub(SectionsManager, "removeSectionCard");
+
+ feed.onAction(
+ ac.WebExtEvent(at.WEBEXT_DISMISS, { source: "Foo", url: "bar.com" })
+ );
+
+ assert.calledOnce(stub);
+ assert.calledWith(stub, "Foo", "bar.com");
+ });
+ it("should call the feed's moveSection on SECTION_MOVE", () => {
+ sinon.stub(feed, "moveSection");
+ const id = "topsites";
+ const direction = +1;
+ feed.onAction({ type: "SECTION_MOVE", data: { id, direction } });
+
+ assert.calledOnce(feed.moveSection);
+ assert.calledWith(feed.moveSection, id, direction);
+ });
+ });
+ describe("#moveSection", () => {
+ it("should Move Down correctly", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("topsites", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,topsites,highlights" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("topstories", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topsites,highlights,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ it("should Move Up correctly", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: true },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("topstories", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,topsites,highlights" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("highlights", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topsites,highlights,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ it("should skip over sections that aren't enabled", () => {
+ feed.store.state.Sections = [
+ { id: "topstories", enabled: false },
+ { id: "highlights", enabled: true },
+ ];
+ feed.moveSection("highlights", -1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "highlights,topsites,topstories" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ feed.store.dispatch.resetHistory();
+ feed.moveSection("topsites", +1);
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: { name: "sectionOrder", value: "topstories,highlights,topsites" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ShortUrl.test.js b/browser/components/newtab/test/unit/lib/ShortUrl.test.js
new file mode 100644
index 0000000000..e0f6688db8
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ShortUrl.test.js
@@ -0,0 +1,104 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { shortURL } from "lib/ShortURL.jsm";
+
+const puny = "xn--kpry57d";
+const idn = "台灣";
+
+describe("shortURL", () => {
+ let globals;
+ let IDNStub;
+ let getPublicSuffixFromHostStub;
+
+ beforeEach(() => {
+ IDNStub = sinon.stub().callsFake(host => host.replace(puny, idn));
+ getPublicSuffixFromHostStub = sinon.stub().returns("com");
+
+ globals = new GlobalOverrider();
+ globals.set("IDNService", { convertToDisplayIDN: IDNStub });
+ globals.set("Services", {
+ eTLD: { getPublicSuffixFromHost: getPublicSuffixFromHostStub },
+ });
+ });
+
+ afterEach(() => {
+ globals.restore();
+ });
+
+ it("should return a blank string if url is falsey", () => {
+ assert.equal(shortURL({ url: false }), "");
+ assert.equal(shortURL({ url: "" }), "");
+ assert.equal(shortURL({}), "");
+ });
+
+ it("should return the 'url' if not a valid url", () => {
+ const checkInvalid = url => assert.equal(shortURL({ url }), url);
+ checkInvalid(true);
+ checkInvalid("something");
+ checkInvalid("http:");
+ checkInvalid("http::double");
+ checkInvalid("http://badport:65536/");
+ });
+
+ it("should remove the eTLD", () => {
+ assert.equal(shortURL({ url: "http://com.blah.com" }), "com.blah");
+ });
+
+ it("should convert host to idn when calling shortURL", () => {
+ assert.equal(shortURL({ url: `http://${puny}.blah.com` }), `${idn}.blah`);
+ });
+
+ it("should get the hostname from .url", () => {
+ assert.equal(shortURL({ url: "http://bar.com" }), "bar");
+ });
+
+ it("should not strip out www if not first subdomain", () => {
+ assert.equal(shortURL({ url: "http://foo.www.com" }), "foo.www");
+ });
+
+ it("should convert to lowercase", () => {
+ assert.equal(shortURL({ url: "HTTP://FOO.COM" }), "foo");
+ });
+
+ it("should not include the port", () => {
+ assert.equal(shortURL({ url: "http://foo.com:8888" }), "foo");
+ });
+
+ it("should return hostname for localhost", () => {
+ getPublicSuffixFromHostStub.throws("insufficient domain levels");
+
+ assert.equal(shortURL({ url: "http://localhost:8000/" }), "localhost");
+ });
+
+ it("should return hostname for ip address", () => {
+ getPublicSuffixFromHostStub.throws("host is ip address");
+
+ assert.equal(shortURL({ url: "http://127.0.0.1/foo" }), "127.0.0.1");
+ });
+
+ it("should return etld for www.gov.uk (www-only non-etld)", () => {
+ getPublicSuffixFromHostStub.returns("gov.uk");
+
+ assert.equal(
+ shortURL({ url: "https://www.gov.uk/countersigning" }),
+ "gov.uk"
+ );
+ });
+
+ it("should return idn etld for www-only non-etld", () => {
+ getPublicSuffixFromHostStub.returns(puny);
+
+ assert.equal(shortURL({ url: `https://www.${puny}/foo` }), idn);
+ });
+
+ it("should return not the protocol for file:", () => {
+ assert.equal(shortURL({ url: "file:///foo/bar.txt" }), "/foo/bar.txt");
+ });
+
+ it("should return not the protocol for about:", () => {
+ assert.equal(shortURL({ url: "about:newtab" }), "newtab");
+ });
+
+ it("should fall back to full url as a last resort", () => {
+ assert.equal(shortURL({ url: "about:" }), "about:");
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SiteClassifier.test.js b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js
new file mode 100644
index 0000000000..a8b09ce1f0
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js
@@ -0,0 +1,252 @@
+import { classifySite } from "lib/SiteClassifier.jsm";
+
+const FAKE_CLASSIFIER_DATA = [
+ {
+ type: "hostname-and-params-match",
+ criteria: [
+ {
+ hostname: "hostnameandparams.com",
+ params: [
+ {
+ key: "param1",
+ value: "val1",
+ },
+ ],
+ },
+ ],
+ weight: 300,
+ },
+ {
+ type: "url-match",
+ criteria: [{ url: "https://fullurl.com/must/match" }],
+ weight: 400,
+ },
+ {
+ type: "params-match",
+ criteria: [
+ {
+ params: [
+ {
+ key: "param1",
+ value: "val1",
+ },
+ {
+ key: "param2",
+ value: "val2",
+ },
+ ],
+ },
+ ],
+ weight: 200,
+ },
+ {
+ type: "params-prefix-match",
+ criteria: [
+ {
+ params: [
+ {
+ key: "client",
+ prefix: "fir",
+ },
+ ],
+ },
+ ],
+ weight: 200,
+ },
+ {
+ type: "has-params",
+ criteria: [
+ {
+ params: [{ key: "has-param1" }, { key: "has-param2" }],
+ },
+ ],
+ weight: 100,
+ },
+ {
+ type: "search-engine",
+ criteria: [
+ { sld: "google" },
+ { hostname: "bing.com" },
+ { hostname: "duckduckgo.com" },
+ ],
+ weight: 1,
+ },
+ {
+ type: "news-portal",
+ criteria: [
+ { hostname: "yahoo.com" },
+ { hostname: "aol.com" },
+ { hostname: "msn.com" },
+ ],
+ weight: 1,
+ },
+ {
+ type: "social-media",
+ criteria: [{ hostname: "facebook.com" }, { hostname: "twitter.com" }],
+ weight: 1,
+ },
+ {
+ type: "ecommerce",
+ criteria: [{ sld: "amazon" }, { hostname: "ebay.com" }],
+ weight: 1,
+ },
+];
+
+describe("SiteClassifier", () => {
+ function RemoteSettings() {
+ return {
+ get() {
+ return Promise.resolve(FAKE_CLASSIFIER_DATA);
+ },
+ };
+ }
+
+ it("should return the right category", async () => {
+ assert.equal(
+ "hostname-and-params-match",
+ await classifySite(
+ "https://hostnameandparams.com?param1=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://hostnameandparams.com?param1=val",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://hostnameandparams.com?param=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://hostnameandparams.com", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://params.com?param1=val1", RemoteSettings)
+ );
+
+ assert.equal(
+ "url-match",
+ await classifySite("https://fullurl.com/must/match", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("http://fullurl.com/must/match", RemoteSettings)
+ );
+
+ assert.equal(
+ "params-match",
+ await classifySite(
+ "https://example.com?param1=val1&param2=val2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "params-match",
+ await classifySite(
+ "https://example.com?param1=val1&param2=val2&other=other",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://example.com?param1=val2&param2=val1",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?param1&param2", RemoteSettings)
+ );
+
+ assert.equal(
+ "params-prefix-match",
+ await classifySite("https://search.com?client=firefox", RemoteSettings)
+ );
+ assert.equal(
+ "params-prefix-match",
+ await classifySite("https://search.com?client=fir", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite(
+ "https://search.com?client=mozillafirefox",
+ RemoteSettings
+ )
+ );
+
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1=val1&has-param2=val2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1&has-param2",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "has-params",
+ await classifySite(
+ "https://example.com?has-param1&has-param2&other=other",
+ RemoteSettings
+ )
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?has-param1", RemoteSettings)
+ );
+ assert.equal(
+ "other",
+ await classifySite("https://example.com?has-param2", RemoteSettings)
+ );
+
+ assert.equal(
+ "search-engine",
+ await classifySite("https://google.com", RemoteSettings)
+ );
+ assert.equal(
+ "search-engine",
+ await classifySite("https://google.de", RemoteSettings)
+ );
+ assert.equal(
+ "search-engine",
+ await classifySite("http://bing.com/?q=firefox", RemoteSettings)
+ );
+
+ assert.equal(
+ "news-portal",
+ await classifySite("https://yahoo.com", RemoteSettings)
+ );
+
+ assert.equal(
+ "social-media",
+ await classifySite("http://twitter.com/firefox", RemoteSettings)
+ );
+
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://amazon.com", RemoteSettings)
+ );
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://amazon.ca", RemoteSettings)
+ );
+ assert.equal(
+ "ecommerce",
+ await classifySite("https://ebay.com", RemoteSettings)
+ );
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/Store.test.js b/browser/components/newtab/test/unit/lib/Store.test.js
new file mode 100644
index 0000000000..eeeef3bf51
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/Store.test.js
@@ -0,0 +1,305 @@
+import { addNumberReducer, FakePrefs } from "test/unit/utils";
+import { createStore } from "redux";
+import injector from "inject!lib/Store.jsm";
+
+describe("Store", () => {
+ let Store;
+ let sandbox;
+ let store;
+ let dbStub;
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ function ActivityStreamMessageChannel(options) {
+ this.dispatch = options.dispatch;
+ this.createChannel = sandbox.spy();
+ this.destroyChannel = sandbox.spy();
+ this.middleware = sandbox.spy(s => next => action => next(action));
+ this.simulateMessagesForExistingTabs = sandbox.stub();
+ }
+ dbStub = sandbox.stub().resolves();
+ function FakeActivityStreamStorage() {
+ this.db = {};
+ sinon.stub(this, "db").get(dbStub);
+ }
+ ({ Store } = injector({
+ "lib/ActivityStreamMessageChannel.jsm": { ActivityStreamMessageChannel },
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "lib/ActivityStreamStorage.jsm": {
+ ActivityStreamStorage: FakeActivityStreamStorage,
+ },
+ }));
+ store = new Store();
+ sandbox.stub(store, "_initIndexedDB").resolves();
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+ it("should have a .feeds property that is a Map", () => {
+ assert.instanceOf(store.feeds, Map);
+ assert.equal(store.feeds.size, 0, ".feeds.size");
+ });
+ it("should have a redux store at ._store", () => {
+ assert.ok(store._store);
+ assert.property(store, "dispatch");
+ assert.property(store, "getState");
+ });
+ it("should create a ActivityStreamMessageChannel with the right dispatcher", () => {
+ assert.ok(store.getMessageChannel());
+ assert.equal(store.getMessageChannel().dispatch, store.dispatch);
+ assert.equal(store.getMessageChannel(), store._messageChannel);
+ });
+ it("should connect the ActivityStreamMessageChannel's middleware", () => {
+ store.dispatch({ type: "FOO" });
+ assert.calledOnce(store._messageChannel.middleware);
+ });
+ describe("#initFeed", () => {
+ it("should add an instance of the feed to .feeds", () => {
+ class Foo {}
+ store._prefs.set("foo", true);
+ store.init(new Map([["foo", () => new Foo()]]));
+ store.initFeed("foo");
+
+ assert.isTrue(store.feeds.has("foo"), "foo is set");
+ assert.instanceOf(store.feeds.get("foo"), Foo);
+ });
+ it("should call the feed's onAction with uninit action if it exists", () => {
+ let feed;
+ function createFeed() {
+ feed = { onAction: sinon.spy() };
+ return feed;
+ }
+ const action = { type: "FOO" };
+ store._feedFactories = new Map([["foo", createFeed]]);
+
+ store.initFeed("foo", action);
+
+ assert.calledOnce(feed.onAction);
+ assert.calledWith(feed.onAction, action);
+ });
+ it("should add a .store property to the feed", () => {
+ class Foo {}
+ store._feedFactories = new Map([["foo", () => new Foo()]]);
+ store.initFeed("foo");
+
+ assert.propertyVal(store.feeds.get("foo"), "store", store);
+ });
+ });
+ describe("#uninitFeed", () => {
+ it("should not throw if no feed with that name exists", () => {
+ assert.doesNotThrow(() => {
+ store.uninitFeed("bar");
+ });
+ });
+ it("should call the feed's onAction with uninit action if it exists", () => {
+ let feed;
+ function createFeed() {
+ feed = { onAction: sinon.spy() };
+ return feed;
+ }
+ const action = { type: "BAR" };
+ store._feedFactories = new Map([["foo", createFeed]]);
+ store.initFeed("foo");
+
+ store.uninitFeed("foo", action);
+
+ assert.calledOnce(feed.onAction);
+ assert.calledWith(feed.onAction, action);
+ });
+ it("should remove the feed from .feeds", () => {
+ class Foo {}
+ store._feedFactories = new Map([["foo", () => new Foo()]]);
+
+ store.initFeed("foo");
+ store.uninitFeed("foo");
+
+ assert.isFalse(store.feeds.has("foo"), "foo is not in .feeds");
+ });
+ });
+ describe("onPrefChanged", () => {
+ beforeEach(() => {
+ sinon.stub(store, "initFeed");
+ sinon.stub(store, "uninitFeed");
+ store._prefs.set("foo", false);
+ store.init(new Map([["foo", () => ({})]]));
+ });
+ it("should initialize the feed if called with true", () => {
+ store.onPrefChanged("foo", true);
+
+ assert.calledWith(store.initFeed, "foo");
+ assert.notCalled(store.uninitFeed);
+ });
+ it("should uninitialize the feed if called with false", () => {
+ store.onPrefChanged("foo", false);
+
+ assert.calledWith(store.uninitFeed, "foo");
+ assert.notCalled(store.initFeed);
+ });
+ it("should do nothing if not an expected feed", () => {
+ store.onPrefChanged("bar", false);
+
+ assert.notCalled(store.initFeed);
+ assert.notCalled(store.uninitFeed);
+ });
+ });
+ describe("#init", () => {
+ it("should call .initFeed with each key", async () => {
+ sinon.stub(store, "initFeed");
+ store._prefs.set("foo", true);
+ store._prefs.set("bar", true);
+ await store.init(
+ new Map([
+ ["foo", () => {}],
+ ["bar", () => {}],
+ ])
+ );
+ assert.calledWith(store.initFeed, "foo");
+ assert.calledWith(store.initFeed, "bar");
+ });
+ it("should call _initIndexedDB", async () => {
+ await store.init(new Map());
+
+ assert.calledOnce(store._initIndexedDB);
+ assert.calledWithExactly(store._initIndexedDB, "feeds.telemetry");
+ });
+ it("should access the db property of indexedDB", async () => {
+ store._initIndexedDB.restore();
+ await store.init(new Map());
+
+ assert.calledOnce(dbStub);
+ });
+ it("should reset ActivityStreamStorage telemetry if opening the db fails", async () => {
+ store._initIndexedDB.restore();
+ // Force an IndexedDB error
+ dbStub.rejects();
+
+ await store.init(new Map());
+
+ assert.calledOnce(dbStub);
+ assert.isNull(store.dbStorage.telemetry);
+ });
+ it("should not initialize the feed if the Pref is set to false", async () => {
+ sinon.stub(store, "initFeed");
+ store._prefs.set("foo", false);
+ await store.init(new Map([["foo", () => {}]]));
+ assert.notCalled(store.initFeed);
+ });
+ it("should observe the pref branch", async () => {
+ sinon.stub(store._prefs, "observeBranch");
+ await store.init(new Map());
+ assert.calledOnce(store._prefs.observeBranch);
+ assert.calledWith(store._prefs.observeBranch, store);
+ });
+ it("should initialize the ActivityStreamMessageChannel channel", async () => {
+ await store.init(new Map());
+ });
+ it("should emit an initial event if provided", async () => {
+ sinon.stub(store, "dispatch");
+ const action = { type: "FOO" };
+
+ await store.init(new Map(), action);
+
+ assert.calledOnce(store.dispatch);
+ assert.calledWith(store.dispatch, action);
+ });
+ it("should initialize the telemtry feed first", () => {
+ 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],
+ ]);
+ store.init(feedFactories);
+ assert.ok(telemetrySpy.calledBefore(fooSpy));
+ });
+ it("should dispatch init/load events", async () => {
+ await store.init(new Map(), { type: "FOO" });
+
+ assert.calledOnce(
+ store.getMessageChannel().simulateMessagesForExistingTabs
+ );
+ });
+ it("should dispatch INIT before LOAD", async () => {
+ const init = { type: "INIT" };
+ const load = { type: "TAB_LOAD" };
+ sandbox.stub(store, "dispatch");
+ store
+ .getMessageChannel()
+ .simulateMessagesForExistingTabs.callsFake(() => store.dispatch(load));
+ await store.init(new Map(), init);
+
+ assert.calledTwice(store.dispatch);
+ assert.equal(store.dispatch.firstCall.args[0], init);
+ assert.equal(store.dispatch.secondCall.args[0], load);
+ });
+ });
+ describe("#uninit", () => {
+ it("should emit an uninit event if provided on init", () => {
+ sinon.stub(store, "dispatch");
+ const action = { type: "BAR" };
+ store.init(new Map(), null, action);
+
+ store.uninit();
+
+ assert.calledOnce(store.dispatch);
+ assert.calledWith(store.dispatch, action);
+ });
+ it("should clear .feeds and ._feedFactories", () => {
+ store._prefs.set("a", true);
+ store.init(
+ new Map([
+ ["a", () => ({})],
+ ["b", () => ({})],
+ ["c", () => ({})],
+ ])
+ );
+
+ store.uninit();
+
+ assert.equal(store.feeds.size, 0);
+ assert.isNull(store._feedFactories);
+ });
+ });
+ describe("#getState", () => {
+ it("should return the redux state", () => {
+ store._store = createStore((prevState = 123) => prevState);
+ const { getState } = store;
+ assert.equal(getState(), 123);
+ });
+ });
+ describe("#dispatch", () => {
+ it("should call .onAction of each feed", async () => {
+ 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.calledWith(sub.onAction, action);
+ });
+ it("should call the reducers", () => {
+ const { dispatch } = store;
+ store._store = createStore(addNumberReducer);
+
+ dispatch({ type: "ADD", data: 14 });
+
+ assert.equal(store.getState(), 14);
+ });
+ });
+ describe("#subscribe", () => {
+ it("should subscribe to changes to the store", () => {
+ const sub = sinon.spy();
+ const action = { type: "FOO" };
+
+ store.subscribe(sub);
+ store.dispatch(action);
+
+ assert.calledOnce(sub);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js
new file mode 100644
index 0000000000..4dd5febdb2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js
@@ -0,0 +1,76 @@
+import { SYSTEM_TICK_INTERVAL, SystemTickFeed } from "lib/SystemTickFeed.jsm";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import { GlobalOverrider } from "test/unit/utils";
+
+describe("System Tick Feed", () => {
+ let globals;
+ let instance;
+ let clock;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ clock = sinon.useFakeTimers();
+
+ instance = new SystemTickFeed();
+ instance.store = {
+ getState() {
+ return {};
+ },
+ dispatch() {},
+ };
+ });
+ afterEach(() => {
+ globals.restore();
+ clock.restore();
+ });
+ it("should create a SystemTickFeed", () => {
+ assert.instanceOf(instance, SystemTickFeed);
+ });
+ it("should fire SYSTEM_TICK events at configured interval", () => {
+ globals.set("ChromeUtils", {
+ idleDispatch: f => f(),
+ });
+ let expectation = sinon
+ .mock(instance.store)
+ .expects("dispatch")
+ .twice()
+ .withExactArgs({ type: at.SYSTEM_TICK });
+
+ instance.onAction({ type: at.INIT });
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+ it("should not fire SYSTEM_TICK events after UNINIT", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch").never();
+
+ instance.onAction({ type: at.UNINIT });
+ clock.tick(SYSTEM_TICK_INTERVAL * 2);
+ expectation.verify();
+ });
+ it("should not fire SYSTEM_TICK events while the user is away", () => {
+ let expectation = sinon.mock(instance.store).expects("dispatch").never();
+
+ instance.onAction({ type: at.INIT });
+ instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 };
+ clock.tick(SYSTEM_TICK_INTERVAL * 3);
+ expectation.verify();
+ instance.onAction({ type: at.UNINIT });
+ });
+ it("should fire SYSTEM_TICK immediately when the user is active again", () => {
+ globals.set("ChromeUtils", {
+ idleDispatch: f => f(),
+ });
+ let expectation = sinon
+ .mock(instance.store)
+ .expects("dispatch")
+ .once()
+ .withExactArgs({ type: at.SYSTEM_TICK });
+
+ instance.onAction({ type: at.INIT });
+ instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 };
+ clock.tick(SYSTEM_TICK_INTERVAL * 3);
+ instance.observe();
+ expectation.verify();
+ instance.onAction({ type: at.UNINIT });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
new file mode 100644
index 0000000000..1606f98e94
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js
@@ -0,0 +1,2606 @@
+/* global Services */
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "common/Actions.sys.mjs";
+import {
+ ASRouterEventPing,
+ BasePing,
+ ImpressionStatsPing,
+ SessionPing,
+ UserEventPing,
+} from "test/schemas/pings";
+import { FAKE_GLOBAL_PREFS, GlobalOverrider } from "test/unit/utils";
+import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm";
+import injector from "inject!lib/TelemetryFeed.jsm";
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs";
+
+const FAKE_UUID = "{foo-123-foo}";
+const FAKE_ROUTER_MESSAGE_PROVIDER = [{ id: "cfr", enabled: true }];
+const FAKE_TELEMETRY_ID = "foo123";
+
+// eslint-disable-next-line max-statements
+describe("TelemetryFeed", () => {
+ let globals;
+ let sandbox;
+ let expectedUserPrefs;
+ let browser = {
+ getAttribute() {
+ return "true";
+ },
+ };
+ let instance;
+ let clock;
+ let fakeHomePageUrl;
+ let fakeHomePage;
+ let fakeExtensionSettingsStore;
+ let ExperimentAPI = { getExperimentMetaData: () => {} };
+ class PingCentre {
+ sendPing() {}
+ uninit() {}
+ sendStructuredIngestionPing() {}
+ }
+ class UTEventReporting {
+ sendUserEvent() {}
+ sendSessionEndEvent() {}
+ uninit() {}
+ }
+
+ // Reset the global prefs before importing the `TelemetryFeed` module, to
+ // avoid a coverage miss caused by preference pollution when this test and
+ // `ActivityStream.test.js` are run together.
+ //
+ // The `TelemetryFeed` module defines a lazy `contextId` getter, which the
+ // `XPCOMUtils.defineLazyGetter` mock (defined in `unit-entry.js`) executes
+ // immediately, as soon as the module is imported.
+ //
+ // If this test runs first, there's no coverage miss: this test will load
+ // the `TelemetryFeed` module and run the lazy `contextId` getter, which will
+ // generate a fake context ID and store it in `FAKE_GLOBAL_PREFS`, covering
+ // all branches in the module. When `ActivityStream.test.js` runs, it'll load
+ // `TelemetryFeed` and run the lazy getter a second time, which will use the
+ // existing fake context ID from `FAKE_GLOBAL_PREFS` instead of generating a
+ // new one.
+ //
+ // But, if `ActivityStream.test.js` runs first, then loading `TelemetryFeed` a
+ // second time as part of this test will use the existing fake context ID from
+ // `FAKE_GLOBAL_PREFS`, missing coverage for the branch to generate a new
+ // context ID.
+ FAKE_GLOBAL_PREFS.clear();
+
+ const {
+ TelemetryFeed,
+ USER_PREFS_ENCODING,
+ PREF_IMPRESSION_ID,
+ TELEMETRY_PREF,
+ EVENTS_TELEMETRY_PREF,
+ STRUCTURED_INGESTION_ENDPOINT_PREF,
+ } = injector({
+ "lib/UTEventReporting.sys.mjs": { UTEventReporting },
+ });
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ clock = sinon.useFakeTimers();
+ fakeHomePageUrl = "about:home";
+ fakeHomePage = {
+ get() {
+ return fakeHomePageUrl;
+ },
+ };
+ fakeExtensionSettingsStore = {
+ initialize() {
+ return Promise.resolve();
+ },
+ getSetting() {},
+ };
+ sandbox.spy(global.console, "error");
+ globals.set("AboutNewTab", {
+ newTabURLOverridden: false,
+ newTabURL: "",
+ });
+ globals.set("pktApi", {
+ isUserLoggedIn: () => true,
+ });
+ globals.set("HomePage", fakeHomePage);
+ globals.set("ExtensionSettingsStore", fakeExtensionSettingsStore);
+ globals.set("PingCentre", PingCentre);
+ globals.set("UTEventReporting", UTEventReporting);
+ globals.set("ClientID", {
+ getClientID: sandbox.spy(async () => FAKE_TELEMETRY_ID),
+ });
+ globals.set("ExperimentAPI", ExperimentAPI);
+
+ sandbox
+ .stub(ASRouterPreferences, "providers")
+ .get(() => FAKE_ROUTER_MESSAGE_PROVIDER);
+ instance = new TelemetryFeed();
+ });
+ afterEach(() => {
+ clock.restore();
+ globals.restore();
+ FAKE_GLOBAL_PREFS.clear();
+ ASRouterPreferences.uninit();
+ });
+ describe("#init", () => {
+ it("should create an instance", () => {
+ const testInstance = new TelemetryFeed();
+ assert.isDefined(testInstance);
+ });
+ it("should add .pingCentre, a PingCentre instance", () => {
+ assert.instanceOf(instance.pingCentre, PingCentre);
+ });
+ it("should add .utEvents, a UTEventReporting instance", () => {
+ assert.instanceOf(instance.utEvents, UTEventReporting);
+ });
+ it("should make this.browserOpenNewtabStart() observe browser-open-newtab-start", () => {
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ assert.calledWithExactly(
+ Services.obs.addObserver,
+ instance.browserOpenNewtabStart,
+ "browser-open-newtab-start"
+ );
+ });
+ it("should add window open listener", () => {
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ assert.calledWithExactly(
+ Services.obs.addObserver,
+ instance._addWindowListeners,
+ "domwindowopened"
+ );
+ });
+ it("should add TabPinned event listener on new windows", () => {
+ const stub = { addEventListener: sandbox.stub() };
+ sandbox.spy(Services.obs, "addObserver");
+
+ instance.init();
+
+ assert.calledTwice(Services.obs.addObserver);
+ const [cb] = Services.obs.addObserver.secondCall.args;
+ cb(stub);
+ assert.calledTwice(stub.addEventListener);
+ assert.calledWithExactly(
+ stub.addEventListener,
+ "unload",
+ instance.handleEvent
+ );
+ assert.calledWithExactly(
+ stub.addEventListener,
+ "TabPinned",
+ instance.handleEvent
+ );
+ });
+ it("should create impression id if none exists", () => {
+ assert.equal(instance._impressionId, FAKE_UUID);
+ });
+ it("should set impression id if it exists", () => {
+ FAKE_GLOBAL_PREFS.set(PREF_IMPRESSION_ID, "fakeImpressionId");
+ assert.equal(new TelemetryFeed()._impressionId, "fakeImpressionId");
+ });
+ it("should register listeners on existing windows", () => {
+ const stub = sandbox.stub();
+ globals.set({
+ Services: {
+ ...Services,
+ wm: { getEnumerator: () => [{ addEventListener: stub }] },
+ },
+ });
+
+ instance.init();
+
+ assert.calledTwice(stub);
+ assert.calledWithExactly(stub, "unload", instance.handleEvent);
+ assert.calledWithExactly(stub, "TabPinned", instance.handleEvent);
+ });
+ describe("telemetry pref changes from false to true", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, false);
+ instance = new TelemetryFeed();
+
+ assert.propertyVal(instance, "telemetryEnabled", false);
+ });
+
+ it("should set the enabled property to true", () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+
+ assert.propertyVal(instance, "telemetryEnabled", true);
+ });
+ });
+ describe("events telemetry pref changes from false to true", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, false);
+ instance = new TelemetryFeed();
+
+ assert.propertyVal(instance, "eventTelemetryEnabled", false);
+ });
+
+ it("should set the enabled property to true", () => {
+ instance._prefs.set(EVENTS_TELEMETRY_PREF, true);
+
+ assert.propertyVal(instance, "eventTelemetryEnabled", true);
+ });
+ });
+ it("should set two scalars for deletion-request", () => {
+ sandbox.spy(Services.telemetry, "scalarSet");
+
+ instance.init();
+
+ assert.calledTwice(Services.telemetry.scalarSet);
+
+ // impression_id
+ let [type, value] = Services.telemetry.scalarSet.firstCall.args;
+ assert.equal(type, "deletion.request.impression_id");
+ assert.equal(value, instance._impressionId);
+
+ // context_id
+ [type, value] = Services.telemetry.scalarSet.secondCall.args;
+ assert.equal(type, "deletion.request.context_id");
+ assert.equal(value, FAKE_UUID);
+ });
+ describe("#_beginObservingNewtabPingPrefs", () => {
+ it("should record initial metrics from newtab prefs", () => {
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.feeds.topsites",
+ true
+ );
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 3
+ );
+ FAKE_GLOBAL_PREFS.set(
+ "browser.topsites.blockedSponsors",
+ '["mozilla"]'
+ );
+
+ sandbox.spy(Glean.topsites.enabled, "set");
+ sandbox.spy(Glean.topsites.rows, "set");
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ assert.calledOnce(Glean.topsites.enabled.set);
+ assert.calledWith(Glean.topsites.enabled.set, true);
+ assert.calledOnce(Glean.topsites.rows.set);
+ assert.calledWith(Glean.topsites.rows.set, 3);
+ assert.calledOnce(Glean.newtab.blockedSponsors.set);
+ assert.calledWith(Glean.newtab.blockedSponsors.set, ["mozilla"]);
+ });
+
+ it("should not record blocked sponsor metrics when bad json string is passed", () => {
+ FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "BAD[JSON]");
+
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ assert.notCalled(Glean.newtab.blockedSponsors.set);
+ });
+
+ it("should record new metrics for newtab pref changes", () => {
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 3
+ );
+ FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "[]");
+ sandbox.spy(Glean.topsites.rows, "set");
+ sandbox.spy(Glean.newtab.blockedSponsors, "set");
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ Services.prefs.setIntPref(
+ "browser.newtabpage.activity-stream.topSitesRows",
+ 2
+ );
+
+ Services.prefs.setStringPref(
+ "browser.topsites.blockedSponsors",
+ '["mozilla"]'
+ );
+
+ assert.calledTwice(Glean.topsites.rows.set);
+ assert.calledWith(Glean.topsites.rows.set.firstCall, 3);
+ assert.calledWith(Glean.topsites.rows.set.secondCall, 2);
+ assert.calledWith(Glean.newtab.blockedSponsors.set.firstCall, []);
+ assert.calledWith(Glean.newtab.blockedSponsors.set.secondCall, [
+ "mozilla",
+ ]);
+ });
+ it("should ignore changes to other prefs", () => {
+ FAKE_GLOBAL_PREFS.set("some.other.pref", 123);
+ FAKE_GLOBAL_PREFS.set(
+ "browser.newtabpage.activity-stream.impressionId",
+ "{foo-123-foo}"
+ );
+
+ instance = new TelemetryFeed();
+ instance.init();
+
+ Services.prefs.setIntPref("some.other.pref", 456);
+ Services.prefs.setCharPref(
+ "browser.newtabpage.activity-stream.impressionId",
+ "{foo-456-foo}"
+ );
+ });
+ });
+ });
+ describe("#handleEvent", () => {
+ it("should dispatch a TAB_PINNED_EVENT", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [{ gBrowser: { tabs: [{ pinned: true }] } }],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping, "event", "TABPINNED");
+ assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU");
+ assert.propertyVal(ping, "session_id", "n/a");
+ assert.propertyVal(ping.value, "total_pinned_tabs", 1);
+ });
+ it("should skip private windows", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({ PrivateBrowsingUtils: { isWindowPrivate: () => true } });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.notCalled(instance.sendEvent);
+ });
+ it("should return the correct value for total_pinned_tabs", () => {
+ sandbox.stub(instance, "sendEvent");
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [
+ {
+ gBrowser: { tabs: [{ pinned: true }, { pinned: false }] },
+ },
+ ],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping, "event", "TABPINNED");
+ assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU");
+ assert.propertyVal(ping, "session_id", "n/a");
+ assert.propertyVal(ping.value, "total_pinned_tabs", 1);
+ });
+ it("should return the correct value for total_pinned_tabs (when private windows are open)", () => {
+ sandbox.stub(instance, "sendEvent");
+ const privateWinStub = sandbox
+ .stub()
+ .onCall(0)
+ .returns(false)
+ .onCall(1)
+ .returns(true);
+ globals.set({
+ PrivateBrowsingUtils: { isWindowPrivate: privateWinStub },
+ });
+ globals.set({
+ Services: {
+ ...Services,
+ wm: {
+ getEnumerator: () => [
+ {
+ gBrowser: { tabs: [{ pinned: true }, { pinned: true }] },
+ },
+ ],
+ },
+ },
+ });
+
+ instance.handleEvent({ type: "TabPinned", target: {} });
+
+ assert.calledOnce(instance.sendEvent);
+ const [ping] = instance.sendEvent.firstCall.args;
+ assert.propertyVal(ping.value, "total_pinned_tabs", 0);
+ });
+ it("should unregister the event listeners", () => {
+ const stub = { removeEventListener: sandbox.stub() };
+
+ instance.handleEvent({ type: "unload", target: stub });
+
+ assert.calledTwice(stub.removeEventListener);
+ assert.calledWithExactly(
+ stub.removeEventListener,
+ "unload",
+ instance.handleEvent
+ );
+ assert.calledWithExactly(
+ stub.removeEventListener,
+ "TabPinned",
+ instance.handleEvent
+ );
+ });
+ });
+ describe("#addSession", () => {
+ it("should add a session and return it", () => {
+ const session = instance.addSession("foo");
+
+ assert.equal(instance.sessions.get("foo"), session);
+ });
+ it("should set the session_id", () => {
+ sandbox.spy(Services.uuid, "generateUUID");
+
+ const session = instance.addSession("foo");
+
+ assert.calledOnce(Services.uuid.generateUUID);
+ assert.equal(
+ session.session_id,
+ Services.uuid.generateUUID.firstCall.returnValue
+ );
+ });
+ it("should set the page if a url parameter is given", () => {
+ const session = instance.addSession("foo", "about:monkeys");
+
+ assert.propertyVal(session, "page", "about:monkeys");
+ });
+ it("should set the page prop to 'unknown' if no URL parameter given", () => {
+ const session = instance.addSession("foo");
+
+ assert.propertyVal(session, "page", "unknown");
+ });
+ it("should set the perf type when lacking timestamp", () => {
+ const session = instance.addSession("foo");
+
+ assert.propertyVal(session.perf, "load_trigger_type", "unexpected");
+ });
+ it("should set load_trigger_type to first_window_opened on the first about:home seen", () => {
+ const session = instance.addSession("foo", "about:home");
+
+ assert.propertyVal(
+ session.perf,
+ "load_trigger_type",
+ "first_window_opened"
+ );
+ });
+ it("should not set load_trigger_type to first_window_opened on the second about:home seen", () => {
+ instance.addSession("foo", "about:home");
+
+ const session2 = instance.addSession("foo", "about:home");
+
+ assert.notPropertyVal(
+ session2.perf,
+ "load_trigger_type",
+ "first_window_opened"
+ );
+ });
+ it("should set load_trigger_ts to the value of the process start timestamp", () => {
+ const session = instance.addSession("foo", "about:home");
+
+ assert.propertyVal(session.perf, "load_trigger_ts", 1588010448000);
+ });
+ it("should create a valid session ping on the first about:home seen", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ });
+ it("should be a valid ping with the data_late_by_ms perf", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+ instance.saveSessionPerfData("foo", { topsites_data_late_by_ms: 10 });
+ instance.saveSessionPerfData("foo", { highlights_data_late_by_ms: 20 });
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "highlights_data_late_by_ms",
+ 20
+ );
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "topsites_data_late_by_ms",
+ 10
+ );
+ });
+ it("should be a valid ping with the topsites stats perf", () => {
+ // Add a session
+ const portID = "foo";
+ const session = instance.addSession(portID, "about:home");
+ instance.saveSessionPerfData("foo", {
+ topsites_icon_stats: {
+ custom_screenshot: 0,
+ screenshot_with_icon: 2,
+ screenshot: 1,
+ tippytop: 2,
+ rich_icon: 1,
+ no_image: 0,
+ },
+ topsites_pinned: 3,
+ topsites_search_shortcuts: 2,
+ });
+
+ // Create a ping referencing the session
+ const ping = instance.createSessionEndEvent(session);
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(
+ instance.sessions.get("foo").perf.topsites_icon_stats,
+ "screenshot_with_icon",
+ 2
+ );
+ assert.equal(instance.sessions.get("foo").perf.topsites_pinned, 3);
+ assert.equal(
+ instance.sessions.get("foo").perf.topsites_search_shortcuts,
+ 2
+ );
+ });
+ });
+
+ describe("#browserOpenNewtabStart", () => {
+ it("should call ChromeUtils.addProfilerMarker with browser-open-newtab-start", () => {
+ globals.set("ChromeUtils", {
+ addProfilerMarker: sandbox.stub(),
+ });
+
+ sandbox.stub(global.Cu, "now").returns(12345);
+
+ instance.browserOpenNewtabStart();
+
+ assert.calledOnce(ChromeUtils.addProfilerMarker);
+ assert.calledWithExactly(
+ ChromeUtils.addProfilerMarker,
+ "UserTiming",
+ 12345,
+ "browser-open-newtab-start"
+ );
+ });
+ });
+
+ describe("#endSession", () => {
+ it("should not throw if there is no session for the given port ID", () => {
+ assert.doesNotThrow(() => instance.endSession("doesn't exist"));
+ });
+ it("should add a session_duration integer if there is a visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "sendEvent");
+ const session = instance.addSession("foo");
+ session.perf.visibility_event_rcvd_ts = 444.4732;
+
+ instance.endSession("foo");
+
+ assert.isNumber(session.session_duration);
+ assert.ok(
+ Number.isInteger(session.session_duration),
+ "session_duration should be an integer"
+ );
+ });
+ it("shouldn't send session ping if there's no visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "sendEvent");
+ instance.addSession("foo");
+
+ instance.endSession("foo");
+
+ assert.notCalled(instance.sendEvent);
+ assert.isFalse(instance.sessions.has("foo"));
+ });
+ it("should remove the session from .sessions", () => {
+ sandbox.stub(instance, "sendEvent");
+ instance.addSession("foo");
+
+ instance.endSession("foo");
+
+ assert.isFalse(instance.sessions.has("foo"));
+ });
+ it("should call createSessionSendEvent and sendEvent with the sesssion", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ sandbox.stub(instance, "sendEvent");
+ sandbox.stub(instance, "createSessionEndEvent");
+ sandbox.stub(instance.utEvents, "sendSessionEndEvent");
+ const session = instance.addSession("foo");
+ session.perf.visibility_event_rcvd_ts = 444.4732;
+
+ instance.endSession("foo");
+
+ // Did we call sendEvent with the result of createSessionEndEvent?
+ assert.calledWith(instance.createSessionEndEvent, session);
+
+ let sessionEndEvent =
+ instance.createSessionEndEvent.firstCall.returnValue;
+ assert.calledWith(instance.sendEvent, sessionEndEvent);
+ assert.calledWith(instance.utEvents.sendSessionEndEvent, sessionEndEvent);
+ });
+ });
+ describe("ping creators", () => {
+ beforeEach(() => {
+ for (const pref of Object.keys(USER_PREFS_ENCODING)) {
+ FAKE_GLOBAL_PREFS.set(pref, true);
+ expectedUserPrefs |= USER_PREFS_ENCODING[pref];
+ }
+ instance.init();
+ });
+ describe("#createPing", () => {
+ it("should create a valid base ping without a session if no portID is supplied", async () => {
+ const ping = await instance.createPing();
+ assert.validate(ping, BasePing);
+ assert.notProperty(ping, "session_id");
+ assert.notProperty(ping, "page");
+ });
+ it("should create a valid base ping with session info if a portID is supplied", async () => {
+ // Add a session
+ const portID = "foo";
+ instance.addSession(portID, "about:home");
+ const sessionID = instance.sessions.get(portID).session_id;
+
+ // Create a ping referencing the session
+ const ping = await instance.createPing(portID);
+ assert.validate(ping, BasePing);
+
+ // Make sure we added the right session-related stuff to the ping
+ assert.propertyVal(ping, "session_id", sessionID);
+ assert.propertyVal(ping, "page", "about:home");
+ });
+ it("should create an unexpected base ping if no session yet portID is supplied", async () => {
+ const ping = await instance.createPing("foo");
+
+ assert.validate(ping, BasePing);
+ assert.propertyVal(ping, "page", "unknown");
+ assert.propertyVal(
+ instance.sessions.get("foo").perf,
+ "load_trigger_type",
+ "unexpected"
+ );
+ });
+ it("should create a base ping with user_prefs", async () => {
+ const ping = await instance.createPing("foo");
+
+ assert.validate(ping, BasePing);
+ assert.propertyVal(ping, "user_prefs", expectedUserPrefs);
+ });
+ });
+ describe("#createUserEvent", () => {
+ it("should create a valid event", async () => {
+ const portID = "foo";
+ const data = { source: "TOP_SITES", event: "CLICK" };
+ const action = ac.AlsoToMain(ac.UserEvent(data), portID);
+ const session = instance.addSession(portID);
+
+ const ping = await instance.createUserEvent(action);
+
+ // Is it valid?
+ assert.validate(ping, UserEventPing);
+ // Does it have the right session_id?
+ assert.propertyVal(ping, "session_id", session.session_id);
+ });
+ });
+ describe("#createSessionEndEvent", () => {
+ it("should create a valid event", async () => {
+ const ping = await instance.createSessionEndEvent({
+ session_id: FAKE_UUID,
+ page: "about:newtab",
+ session_duration: 12345,
+ perf: {
+ load_trigger_ts: 10,
+ load_trigger_type: "menu_plus_or_keyboard",
+ visibility_event_rcvd_ts: 20,
+ is_preloaded: true,
+ },
+ });
+
+ // Is it valid?
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(ping, "session_id", FAKE_UUID);
+ assert.propertyVal(ping, "page", "about:newtab");
+ assert.propertyVal(ping, "session_duration", 12345);
+ });
+ it("should create a valid unexpected session event", async () => {
+ const ping = await instance.createSessionEndEvent({
+ session_id: FAKE_UUID,
+ page: "about:newtab",
+ session_duration: 12345,
+ perf: {
+ load_trigger_type: "unexpected",
+ is_preloaded: true,
+ },
+ });
+
+ // Is it valid?
+ assert.validate(ping, SessionPing);
+ assert.propertyVal(ping, "session_id", FAKE_UUID);
+ assert.propertyVal(ping, "page", "about:newtab");
+ assert.propertyVal(ping, "session_duration", 12345);
+ assert.propertyVal(ping.perf, "load_trigger_type", "unexpected");
+ });
+ });
+ });
+ describe("#createImpressionStats", () => {
+ it("should create a valid impression stats ping", async () => {
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "source", "POCKET");
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid click ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, click: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "click", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid block ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, block: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "block", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should create a valid pocket ping", async () => {
+ const tiles = [{ id: 10001, pos: 2 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles, pocket: 0 });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.propertyVal(ping, "pocket", 0);
+ assert.propertyVal(ping, "tiles", tiles);
+ });
+ it("should pass shim if it is available to impression ping", async () => {
+ const tiles = [{ id: 10001, pos: 2, shim: 1234 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.propertyVal(ping, "tiles", tiles);
+ assert.propertyVal(ping.tiles[0], "shim", tiles[0].shim);
+ });
+ it("should not include client_id and session_id", async () => {
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+ const ping = await instance.createImpressionStats(
+ au.getPortIdOfSender(action),
+ action.data
+ );
+
+ assert.validate(ping, ImpressionStatsPing);
+ assert.notProperty(ping, "client_id");
+ assert.notProperty(ping, "session_id");
+ });
+ });
+ describe("#applyCFRPolicy", () => {
+ it("should use client_id and message_id in prerelease", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "nightly";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ it("should use impression_id and bucket_id in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ it("should use impression_id and bucket_id in Private Browsing", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ is_private: true,
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in Private Browsing", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ is_private: true,
+ message_id: "cfr_message_01",
+ bucket_id: "cfr_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyCFRPolicy(data);
+
+ assert.equal(pingType, "cfr");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "cfr_bucket_01");
+ assert.propertyVal(ping, "message_id", "cfr_message_01");
+ });
+ });
+ describe("#applyWhatsNewPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyWhatsNewPolicy({});
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "whats-new-panel");
+ });
+ });
+ describe("#applyInfoBarPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyInfoBarPolicy({});
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "infobar");
+ });
+ });
+ describe("#applyToastNotificationPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ const { ping, pingType } = await instance.applyToastNotificationPolicy(
+ {}
+ );
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "toast_notification");
+ });
+ });
+ describe("#applySpotlightPolicy", () => {
+ it("should set client_id and set pingType", async () => {
+ let pingData = { action: "foo" };
+ const { ping, pingType } = await instance.applySpotlightPolicy(pingData);
+
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.equal(pingType, "spotlight");
+ assert.notProperty(ping, "action");
+ });
+ });
+ describe("#applyMomentsPolicy", () => {
+ it("should use client_id and message_id in prerelease", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "nightly";
+ },
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ assert.propertyVal(ping, "message_id", "moments_message_01");
+ });
+ it("should use impression_id and bucket_id in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ assert.propertyVal(ping, "message_id", "n/a");
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ });
+ it("should use client_id and message_id in the experiment cohort in release", async () => {
+ globals.set("UpdateUtils", {
+ getUpdateChannel() {
+ return "release";
+ },
+ });
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+ const data = {
+ action: "moments_user_event",
+ event: "IMPRESSION",
+ message_id: "moments_message_01",
+ bucket_id: "moments_bucket_01",
+ };
+ const { ping, pingType } = await instance.applyMomentsPolicy(data);
+
+ assert.equal(pingType, "moments");
+ assert.isUndefined(ping.impression_id);
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "bucket_id", "moments_bucket_01");
+ assert.propertyVal(ping, "message_id", "moments_message_01");
+ });
+ });
+ describe("#applySnippetsPolicy", () => {
+ it("should include client_id", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ const { ping, pingType } = await instance.applySnippetsPolicy(data);
+
+ assert.equal(pingType, "snippets");
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "message_id", "snippets_message_01");
+ });
+ });
+ describe("#applyOnboardingPolicy", () => {
+ it("should include client_id", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(data);
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID);
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ assert.propertyVal(ping, "browser_session_id", "fake_session_id");
+ });
+ it("should include page to event_context if there is a session", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should not set page if it is not in ONBOARDING_ALLOWED_PAGE_VALUES", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ };
+ const session = { page: "foo" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.calledOnce(global.console.error);
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(ping, "event_context", JSON.stringify({}));
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should append page to event_context if it is not empty", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ event_context: JSON.stringify({ foo: "bar" }),
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ foo: "bar", page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ it("should append page to event_context if it is not a JSON serialized string", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTION",
+ message_id: "onboarding_message_01",
+ event_context: "foo",
+ };
+ const session = { page: "about:welcome" };
+ const { ping, pingType } = await instance.applyOnboardingPolicy(
+ data,
+ session
+ );
+
+ assert.equal(pingType, "onboarding");
+ assert.propertyVal(
+ ping,
+ "event_context",
+ JSON.stringify({ value: "foo", page: "about:welcome" })
+ );
+ assert.propertyVal(ping, "message_id", "onboarding_message_01");
+ });
+ });
+ describe("#applyUndesiredEventPolicy", () => {
+ it("should exclude client_id and use impression_id", () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "RS_MISSING_DATA",
+ };
+ const { ping, pingType } = instance.applyUndesiredEventPolicy(data);
+
+ assert.equal(pingType, "undesired-events");
+ assert.isUndefined(ping.client_id);
+ assert.propertyVal(ping, "impression_id", FAKE_UUID);
+ });
+ });
+ describe("#createASRouterEvent", () => {
+ it("should create a valid AS Router event", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "CLICK",
+ message_id: "snippets_message_01",
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.validate(ping, ASRouterEventPing);
+ assert.propertyVal(ping, "event", "CLICK");
+ });
+ it("should call applyCFRPolicy if action equals to cfr_user_event", async () => {
+ const data = {
+ action: "cfr_user_event",
+ event: "IMPRESSION",
+ message_id: "cfr_message_01",
+ };
+ sandbox.stub(instance, "applyCFRPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyCFRPolicy);
+ });
+ it("should call applySnippetsPolicy if action equals to snippets_user_event", async () => {
+ const data = {
+ action: "snippets_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ sandbox.stub(instance, "applySnippetsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySnippetsPolicy);
+ });
+ it("should call applySnippetsPolicy if action equals to snippets_local_testing_user_event", async () => {
+ const data = {
+ action: "snippets_local_testing_user_event",
+ event: "IMPRESSION",
+ message_id: "snippets_message_01",
+ };
+ sandbox.stub(instance, "applySnippetsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySnippetsPolicy);
+ });
+ it("should call applyOnboardingPolicy if action equals to onboarding_user_event", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "onboarding_message_01",
+ };
+ sandbox.stub(instance, "applyOnboardingPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyOnboardingPolicy);
+ });
+ it("should call applyWhatsNewPolicy if action equals to whats-new-panel_user_event", async () => {
+ const data = {
+ action: "whats-new-panel_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "whats-new-panel_message_01",
+ };
+ sandbox.stub(instance, "applyWhatsNewPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyWhatsNewPolicy);
+ });
+ it("should call applyMomentsPolicy if action equals to moments_user_event", async () => {
+ const data = {
+ action: "moments_user_event",
+ event: "CLICK_BUTTON",
+ message_id: "moments_message_01",
+ };
+ sandbox.stub(instance, "applyMomentsPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyMomentsPolicy);
+ });
+ it("should call applySpotlightPolicy if action equals to spotlight_user_event", async () => {
+ const data = {
+ action: "spotlight_user_event",
+ event: "CLICK",
+ message_id: "SPOTLIGHT_MESSAGE_93",
+ };
+ sandbox.stub(instance, "applySpotlightPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applySpotlightPolicy);
+ });
+ it("should call applyToastNotificationPolicy if action equals to toast_notification_user_event", async () => {
+ const data = {
+ action: "toast_notification_user_event",
+ event: "IMPRESSION",
+ message_id: "TEST_TOAST_NOTIFICATION1",
+ };
+ sandbox.stub(instance, "applyToastNotificationPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyToastNotificationPolicy);
+ });
+ it("should call applyUndesiredEventPolicy if action equals to asrouter_undesired_event", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ };
+ sandbox.stub(instance, "applyUndesiredEventPolicy");
+ const action = ac.ASRouterUserEvent(data);
+ await instance.createASRouterEvent(action);
+
+ assert.calledOnce(instance.applyUndesiredEventPolicy);
+ });
+ it("should stringify event_context if it is an Object", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ event_context: { foo: "bar" },
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.propertyVal(ping, "event_context", JSON.stringify({ foo: "bar" }));
+ });
+ it("should not stringify event_context if it is a String", async () => {
+ const data = {
+ action: "asrouter_undesired_event",
+ event: "UNDESIRED_EVENT",
+ event_context: "foo",
+ };
+ const action = ac.ASRouterUserEvent(data);
+ const { ping } = await instance.createASRouterEvent(action);
+
+ assert.propertyVal(ping, "event_context", "foo");
+ });
+ });
+ describe("#sendEventPing", () => {
+ it("should call sendStructuredIngestionEvent", async () => {
+ const data = {
+ action: "activity_stream_user_event",
+ event: "CLICK",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendEventPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ event: "CLICK",
+ browser_session_id: "fake_session_id",
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ it("should stringify value if it is an Object", async () => {
+ const data = {
+ action: "activity_stream_user_event",
+ event: "CLICK",
+ value: { foo: "bar" },
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendEventPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ event: "CLICK",
+ browser_session_id: "fake_session_id",
+ value: JSON.stringify({ foo: "bar" }),
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ });
+ describe("#sendSessionPing", () => {
+ it("should call sendStructuredIngestionEvent", async () => {
+ const data = {
+ action: "activity_stream_session",
+ page: "about:home",
+ session_duration: 10000,
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.sendSessionPing(data);
+
+ const expectedPayload = {
+ client_id: FAKE_TELEMETRY_ID,
+ page: "about:home",
+ session_duration: 10000,
+ };
+ assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload);
+ });
+ });
+ describe("#sendEvent", () => {
+ it("should call sendEventPing on activity_stream_user_event", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = { action: "activity_stream_user_event" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendEventPing");
+
+ instance.sendEvent(event);
+
+ assert.calledOnce(instance.sendEventPing);
+ });
+ it("should call sendSessionPing on activity_stream_session", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = { action: "activity_stream_session" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendSessionPing");
+
+ instance.sendEvent(event);
+
+ assert.calledOnce(instance.sendSessionPing);
+ });
+ });
+ describe("#sendUTEvent", () => {
+ it("should call the UT event function passed in", async () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ const event = {};
+ instance = new TelemetryFeed();
+ sandbox.stub(instance.utEvents, "sendUserEvent");
+
+ await instance.sendUTEvent(event, instance.utEvents.sendUserEvent);
+
+ assert.calledWith(instance.utEvents.sendUserEvent, event);
+ });
+ });
+ describe("#sendStructuredIngestionEvent", () => {
+ it("should call PingCentre sendStructuredIngestionPing", async () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ const event = {};
+ instance = new TelemetryFeed();
+ sandbox.stub(instance.pingCentre, "sendStructuredIngestionPing");
+
+ await instance.sendStructuredIngestionEvent(
+ event,
+ "http://foo.com/base/"
+ );
+
+ assert.calledWith(instance.pingCentre.sendStructuredIngestionPing, event);
+ });
+ });
+ describe("#setLoadTriggerInfo", () => {
+ it("should call saveSessionPerfData w/load_trigger_{ts,type} data", () => {
+ sandbox.stub(global.Cu, "now").returns(12345);
+
+ globals.set("ChromeUtils", {
+ addProfilerMarker: sandbox.stub(),
+ });
+
+ instance.browserOpenNewtabStart();
+
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ instance.addSession("port123");
+
+ instance.setLoadTriggerInfo("port123");
+
+ assert.calledWith(stub, "port123", {
+ load_trigger_ts: 1588010448000 + 12345,
+ load_trigger_type: "menu_plus_or_keyboard",
+ });
+ });
+
+ it("should not call saveSessionPerfData when getting mark throws", () => {
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ instance.addSession("port123");
+
+ instance.setLoadTriggerInfo("port123");
+
+ assert.notCalled(stub);
+ });
+ });
+
+ describe("#saveSessionPerfData", () => {
+ it("should update the given session with the given data", () => {
+ instance.addSession("port123");
+ assert.notProperty(instance.sessions.get("port123"), "fake_ts");
+ const data = { fake_ts: 456, other_fake_ts: 789 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.include(instance.sessions.get("port123").perf, data);
+ });
+
+ it("should call setLoadTriggerInfo if data has visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123");
+ const data = { visibility_event_rcvd_ts: 444455 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(instance.setLoadTriggerInfo);
+ assert.calledWithExactly(instance.setLoadTriggerInfo, "port123");
+ assert.include(instance.sessions.get("port123").perf, data);
+ });
+
+ it("shouldn't call setLoadTriggerInfo if data has no visibility_event_rcvd_ts", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123");
+
+ instance.saveSessionPerfData("port123", { monkeys_ts: 444455 });
+
+ assert.notCalled(instance.setLoadTriggerInfo);
+ });
+
+ it("should not call setLoadTriggerInfo when url is about:home", () => {
+ sandbox.stub(instance, "setLoadTriggerInfo");
+ instance.addSession("port123", "about:home");
+ const data = { visibility_event_rcvd_ts: 444455 };
+
+ instance.saveSessionPerfData("port123", data);
+
+ assert.notCalled(instance.setLoadTriggerInfo);
+ });
+
+ it("should call maybeRecordTopsitesPainted when url is about:home and topsites_first_painted_ts is given", () => {
+ const topsites_first_painted_ts = 44455;
+ const data = { topsites_first_painted_ts };
+ const spy = sandbox.spy();
+
+ sandbox.stub(Services.prefs, "getIntPref").returns(1);
+ globals.set("AboutNewTab", {
+ maybeRecordTopsitesPainted: spy,
+ });
+ instance.addSession("port123", "about:home");
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(spy);
+ assert.calledWith(spy, topsites_first_painted_ts);
+ });
+ it("should record a Glean newtab.opened event with the correct visit_id when visibility event received", () => {
+ const session_id = "decafc0ffee";
+ const page = "about:newtab";
+ const session = { page, perf: {}, session_id };
+ const data = { visibility_event_rcvd_ts: 444455 };
+ sandbox.stub(instance.sessions, "get").returns(session);
+
+ sandbox.spy(Glean.newtab.opened, "record");
+ instance.saveSessionPerfData("port123", data);
+
+ assert.calledOnce(Glean.newtab.opened.record);
+ assert.deepEqual(Glean.newtab.opened.record.firstCall.args[0], {
+ newtab_visit_id: session_id,
+ source: page,
+ });
+ });
+ });
+ describe("#uninit", () => {
+ it("should call .pingCentre.uninit", () => {
+ const stub = sandbox.stub(instance.pingCentre, "uninit");
+
+ instance.uninit();
+
+ assert.calledOnce(stub);
+ });
+ it("should call .utEvents.uninit", () => {
+ const stub = sandbox.stub(instance.utEvents, "uninit");
+
+ instance.uninit();
+
+ assert.calledOnce(stub);
+ });
+ it("should make this.browserOpenNewtabStart() stop observing browser-open-newtab-start and domwindowopened", async () => {
+ await instance.init();
+ sandbox.spy(Services.obs, "removeObserver");
+ sandbox.stub(instance.pingCentre, "uninit");
+
+ await instance.uninit();
+
+ assert.calledTwice(Services.obs.removeObserver);
+ assert.calledWithExactly(
+ Services.obs.removeObserver,
+ instance.browserOpenNewtabStart,
+ "browser-open-newtab-start"
+ );
+ assert.calledWithExactly(
+ Services.obs.removeObserver,
+ instance._addWindowListeners,
+ "domwindowopened"
+ );
+ });
+ });
+ describe("#onAction", () => {
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.clear();
+ });
+ it("should call .init() on an INIT action", () => {
+ const init = sandbox.stub(instance, "init");
+ const sendPageTakeoverData = sandbox.stub(
+ instance,
+ "sendPageTakeoverData"
+ );
+
+ instance.onAction({ type: at.INIT });
+
+ assert.calledOnce(init);
+ assert.calledOnce(sendPageTakeoverData);
+ });
+ it("should call .uninit() on an UNINIT action", () => {
+ const stub = sandbox.stub(instance, "uninit");
+
+ instance.onAction({ type: at.UNINIT });
+
+ assert.calledOnce(stub);
+ });
+ it("should call .handleNewTabInit on a NEW_TAB_INIT action", () => {
+ sandbox.spy(instance, "handleNewTabInit");
+
+ instance.onAction(
+ ac.AlsoToMain({
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:newtab", browser },
+ })
+ );
+
+ assert.calledOnce(instance.handleNewTabInit);
+ });
+ it("should call .addSession() on a NEW_TAB_INIT action", () => {
+ const stub = sandbox.stub(instance, "addSession").returns({ perf: {} });
+ sandbox.stub(instance, "setLoadTriggerInfo");
+
+ instance.onAction(
+ ac.AlsoToMain(
+ {
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:monkeys", browser },
+ },
+ "port123"
+ )
+ );
+
+ assert.calledOnce(stub);
+ assert.calledWith(stub, "port123", "about:monkeys");
+ });
+ it("should call .endSession() on a NEW_TAB_UNLOAD action", () => {
+ const stub = sandbox.stub(instance, "endSession");
+
+ instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "port123"));
+
+ assert.calledWith(stub, "port123");
+ });
+ it("should call .saveSessionPerfData on SAVE_SESSION_PERF_DATA", () => {
+ const stub = sandbox.stub(instance, "saveSessionPerfData");
+ const data = { some_ts: 10 };
+ const action = { type: at.SAVE_SESSION_PERF_DATA, data };
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(stub, "port123", data);
+ });
+ it("should send an event on a TELEMETRY_USER_EVENT action", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+ const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent");
+ const eventCreator = sandbox.stub(instance, "createUserEvent");
+ const action = { type: at.TELEMETRY_USER_EVENT };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventCreator, action);
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ assert.calledWith(utSendUserEvent, eventCreator.returnValue);
+ });
+ it("should send an event on a DISCOVERY_STREAM_USER_EVENT action", () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+ const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent");
+ const eventCreator = sandbox.stub(instance, "createUserEvent");
+ const action = { type: at.DISCOVERY_STREAM_USER_EVENT };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventCreator, {
+ ...action,
+ data: {
+ value: {
+ pocket_logged_in_status: true,
+ },
+ },
+ });
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ assert.calledWith(utSendUserEvent, eventCreator.returnValue);
+ });
+ describe("should call handleASRouterUserEvent on x action", () => {
+ const actions = [
+ at.AS_ROUTER_TELEMETRY_USER_EVENT,
+ msg.TOOLBAR_BADGE_TELEMETRY,
+ msg.TOOLBAR_PANEL_TELEMETRY,
+ msg.MOMENTS_PAGE_TELEMETRY,
+ msg.DOORHANGER_TELEMETRY,
+ ];
+ actions.forEach(type => {
+ it(`${type} action`, () => {
+ FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true);
+ FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true);
+ instance = new TelemetryFeed();
+
+ const eventHandler = sandbox.spy(instance, "handleASRouterUserEvent");
+ const action = {
+ type,
+ data: { event: "CLICK" },
+ };
+
+ instance.onAction(action);
+
+ assert.calledWith(eventHandler, action);
+ });
+ });
+ });
+ it("should send an event on a TELEMETRY_IMPRESSION_STATS action", () => {
+ const sendEvent = sandbox.stub(instance, "sendStructuredIngestionEvent");
+ const eventCreator = sandbox.stub(instance, "createImpressionStats");
+ const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }];
+ const action = ac.ImpressionStats({ source: "POCKET", tiles });
+
+ instance.onAction(action);
+
+ assert.calledWith(
+ eventCreator,
+ au.getPortIdOfSender(action),
+ action.data
+ );
+ assert.calledWith(sendEvent, eventCreator.returnValue);
+ });
+ it("should call .handleDiscoveryStreamImpressionStats on a DISCOVERY_STREAM_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { source: "foo", tiles: [{ id: 1 }] };
+ const action = { type: at.DISCOVERY_STREAM_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleDiscoveryStreamImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(
+ instance.handleDiscoveryStreamImpressionStats,
+ "port123",
+ data
+ );
+ });
+ it("should call .handleDiscoveryStreamLoadedContent on a DISCOVERY_STREAM_LOADED_CONTENT action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { source: "foo", tiles: [{ id: 1 }] };
+ const action = { type: at.DISCOVERY_STREAM_LOADED_CONTENT, data };
+ sandbox.spy(instance, "handleDiscoveryStreamLoadedContent");
+
+ instance.onAction(ac.AlsoToMain(action, "port123"));
+
+ assert.calledWith(
+ instance.handleDiscoveryStreamLoadedContent,
+ "port123",
+ data
+ );
+ });
+ it("should call .handleTopSitesSponsoredImpressionStats on a TOP_SITES_SPONSORED_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { type: "impression", tile_id: 42, position: 1 };
+ const action = { type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleTopSitesSponsoredImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action));
+
+ assert.calledOnce(instance.handleTopSitesSponsoredImpressionStats);
+ assert.deepEqual(
+ instance.handleTopSitesSponsoredImpressionStats.firstCall.args[0].data,
+ data
+ );
+ });
+ });
+ it("should call .handleTopSitesOrganicImpressionStats on a TOP_SITES_ORGANIC_IMPRESSION_STATS action", () => {
+ const session = {};
+ sandbox.stub(instance.sessions, "get").returns(session);
+ const data = { type: "impression", position: 1 };
+ const action = { type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, data };
+ sandbox.spy(instance, "handleTopSitesOrganicImpressionStats");
+
+ instance.onAction(ac.AlsoToMain(action));
+
+ assert.calledOnce(instance.handleTopSitesOrganicImpressionStats);
+ assert.deepEqual(
+ instance.handleTopSitesOrganicImpressionStats.firstCall.args[0].data,
+ data
+ );
+ });
+ describe("#handleNewTabInit", () => {
+ it("should set the session as preloaded if the browser is preloaded", () => {
+ const 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);
+ });
+ it("should set the session as non-preloaded if the browser is non-preloaded", () => {
+ const session = { perf: {} };
+ let nonPreloadedBrowser = {
+ getAttribute() {
+ return "";
+ },
+ };
+ sandbox.stub(instance, "addSession").returns(session);
+
+ instance.onAction(
+ ac.AlsoToMain({
+ type: at.NEW_TAB_INIT,
+ data: { url: "about:newtab", browser: nonPreloadedBrowser },
+ })
+ );
+
+ assert.ok(!session.perf.is_preloaded);
+ });
+ });
+ describe("#SendASRouterUndesiredEvent", () => {
+ it("should call handleASRouterUserEvent", () => {
+ let stub = sandbox.stub(instance, "handleASRouterUserEvent");
+
+ instance.SendASRouterUndesiredEvent({ foo: "bar" });
+
+ assert.calledOnce(stub);
+ let [payload] = stub.firstCall.args;
+ assert.propertyVal(payload.data, "action", "asrouter_undesired_event");
+ assert.propertyVal(payload.data, "foo", "bar");
+ });
+ });
+ describe("#sendPageTakeoverData", () => {
+ let fakePrefs = { "browser.newtabpage.enabled": true };
+
+ beforeEach(() => {
+ globals.set(
+ "Services",
+ Object.assign({}, Services, {
+ prefs: { getBoolPref: key => fakePrefs[key] },
+ })
+ );
+ // Services.prefs = {getBoolPref: key => fakePrefs[key]};
+ sandbox.spy(Glean.newtab.newtabCategory, "set");
+ sandbox.spy(Glean.newtab.homepageCategory, "set");
+ });
+ it("should send correct event data for about:home set to custom URL", async () => {
+ fakeHomePageUrl = "https://searchprovider.com";
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ home_url_category: "other",
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.homepageCategory.set, "other");
+ });
+ it("should send correct event data for about:newtab set to custom URL", async () => {
+ globals.set("AboutNewTab", {
+ newTabURLOverridden: true,
+ newTabURL: "https://searchprovider.com",
+ });
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ newtab_url_category: "other",
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "other");
+ });
+ it("should not send an event if neither about:{home,newtab} are set to custom URL", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.notCalled(sendEvent);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "enabled");
+ assert.calledWith(Glean.newtab.homepageCategory.set, "enabled");
+ });
+ it("should send home_extension_id and newtab_extension_id when appropriate", async () => {
+ const ID = "{abc-foo-bar}";
+ fakeExtensionSettingsStore.getSetting = () => ({ id: ID });
+ instance._prefs.set(TELEMETRY_PREF, true);
+ instance._classifySite = () => Promise.resolve("other");
+ const sendEvent = sandbox.stub(instance, "sendEvent");
+
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(sendEvent);
+ assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA");
+ assert.deepEqual(sendEvent.firstCall.args[0].value, {
+ home_extension_id: ID,
+ newtab_extension_id: ID,
+ });
+ assert.validate(sendEvent.firstCall.args[0], UserEventPing);
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.equal(Glean.newtab.newtabCategory.set.args[0], "extension");
+ assert.equal(Glean.newtab.homepageCategory.set.args[0], "extension");
+ });
+ it("instruments when newtab is disabled", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ fakePrefs["browser.newtabpage.enabled"] = false;
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(Glean.newtab.newtabCategory.set);
+ assert.calledWith(Glean.newtab.newtabCategory.set, "disabled");
+ });
+ it("instruments when homepage is disabled", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ fakeHomePage.overridden = true;
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(Glean.newtab.homepageCategory.set);
+ assert.calledWith(Glean.newtab.homepageCategory.set, "disabled");
+ });
+ it("should send a 'newtab' ping", async () => {
+ instance._prefs.set(TELEMETRY_PREF, true);
+ sandbox.spy(GleanPings.newtab, "submit");
+ await instance.sendPageTakeoverData();
+ assert.calledOnce(GleanPings.newtab.submit);
+ assert.calledWithExactly(GleanPings.newtab.submit, "component_init");
+ });
+ });
+ describe("#sendDiscoveryStreamImpressions", () => {
+ it("should not send impression pings if there is no impression data", () => {
+ const spy = sandbox.spy(instance, "sendEvent");
+ const session = {};
+ instance.sendDiscoveryStreamImpressions("foo", session);
+
+ assert.notCalled(spy);
+ });
+ it("should send impression pings if there is impression data", () => {
+ const spy = sandbox.spy(instance, "sendStructuredIngestionEvent");
+ const session = {
+ impressionSets: {
+ source_foo: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ source_bar: [
+ { id: 3, pos: 0 },
+ { id: 4, pos: 1 },
+ ],
+ },
+ };
+ instance.sendDiscoveryStreamImpressions("foo", session);
+
+ assert.calledTwice(spy);
+ });
+ });
+ describe("#sendDiscoveryStreamLoadedContent", () => {
+ it("should not send loaded content pings if there is no loaded content data", () => {
+ const spy = sandbox.spy(instance, "sendEvent");
+ const session = {};
+ instance.sendDiscoveryStreamLoadedContent("foo", session);
+
+ assert.notCalled(spy);
+ });
+ it("should send loaded content pings if there is loaded content data", () => {
+ const spy = sandbox.spy(instance, "sendStructuredIngestionEvent");
+ const session = {
+ loadedContentSets: {
+ source_foo: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ source_bar: [
+ { id: 3, pos: 0 },
+ { id: 4, pos: 1 },
+ ],
+ },
+ };
+ instance.sendDiscoveryStreamLoadedContent("foo", session);
+
+ assert.calledTwice(spy);
+
+ let [payload] = spy.firstCall.args;
+ let sources = new Set([]);
+ sources.add(payload.source);
+ assert.equal(payload.loaded, 2);
+ assert.deepEqual(
+ payload.tiles,
+ session.loadedContentSets[payload.source]
+ );
+
+ [payload] = spy.secondCall.args;
+ sources.add(payload.source);
+ assert.equal(payload.loaded, 2);
+ assert.deepEqual(
+ payload.tiles,
+ session.loadedContentSets[payload.source]
+ );
+
+ assert.deepEqual(sources, new Set(["source_foo", "source_bar"]));
+ });
+ });
+ describe("#handleDiscoveryStreamImpressionStats", () => {
+ it("should throw for a missing session", () => {
+ assert.throws(() => {
+ instance.handleDiscoveryStreamImpressionStats("a_missing_port", {});
+ }, "Session does not exist.");
+ });
+ it("should store impression to impressionSets", () => {
+ const session = instance.addSession("new_session", "about:newtab");
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "foo",
+ tiles: [{ id: 1, pos: 0 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.equal(Object.keys(session.impressionSets).length, 1);
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [{ id: 1, pos: 0 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ // Add another ping with the same source
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "foo",
+ tiles: [{ id: 2, pos: 1 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ // Add another ping with a different source
+ instance.handleDiscoveryStreamImpressionStats("new_session", {
+ source: "bar",
+ tiles: [{ id: 3, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.equal(Object.keys(session.impressionSets).length, 2);
+ assert.deepEqual(session.impressionSets.foo, {
+ tiles: [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+ assert.deepEqual(session.impressionSets.bar, {
+ tiles: [{ id: 3, pos: 2 }],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+ });
+ it("should instrument pocket impressions", () => {
+ const session_id = "1337cafe";
+ const pos1 = 1;
+ const pos2 = 4;
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.impression, "record");
+
+ instance.handleDiscoveryStreamImpressionStats("_", {
+ source: "foo",
+ tiles: [
+ { id: 1, pos: pos1, type: "organic" },
+ { id: 2, pos: pos2, type: "spoc" },
+ ],
+ window_inner_width: 1000,
+ window_inner_height: 900,
+ });
+
+ assert.calledTwice(Glean.pocket.impression.record);
+ assert.deepEqual(Glean.pocket.impression.record.firstCall.args[0], {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: pos1,
+ });
+ assert.deepEqual(Glean.pocket.impression.record.secondCall.args[0], {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: pos2,
+ });
+ });
+ });
+ describe("#handleDiscoveryStreamLoadedContent", () => {
+ it("should throw for a missing session", () => {
+ assert.throws(() => {
+ instance.handleDiscoveryStreamLoadedContent("a_missing_port", {});
+ }, "Session does not exist.");
+ });
+ it("should store loaded content to loadedContentSets", () => {
+ const session = instance.addSession("new_session", "about:newtab");
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "foo",
+ tiles: [{ id: 1, pos: 0 }],
+ });
+
+ assert.equal(Object.keys(session.loadedContentSets).length, 1);
+ assert.deepEqual(session.loadedContentSets.foo, [{ id: 1, pos: 0 }]);
+
+ // Add another ping with the same source
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "foo",
+ tiles: [{ id: 2, pos: 1 }],
+ });
+
+ assert.deepEqual(session.loadedContentSets.foo, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ]);
+
+ // Add another ping with a different source
+ instance.handleDiscoveryStreamLoadedContent("new_session", {
+ source: "bar",
+ tiles: [{ id: 3, pos: 2 }],
+ });
+
+ assert.equal(Object.keys(session.loadedContentSets).length, 2);
+ assert.deepEqual(session.loadedContentSets.foo, [
+ { id: 1, pos: 0 },
+ { id: 2, pos: 1 },
+ ]);
+ assert.deepEqual(session.loadedContentSets.bar, [{ id: 3, pos: 2 }]);
+ });
+ });
+ describe("#_generateStructuredIngestionEndpoint", () => {
+ it("should generate a valid endpoint", () => {
+ const fakeEndpoint = "http://fakeendpoint.com/base/";
+ const fakeUUID = "{34f24486-f01a-9749-9c5b-21476af1fa77}";
+ const fakeUUIDWithoutBraces = fakeUUID.substring(1, fakeUUID.length - 1);
+ FAKE_GLOBAL_PREFS.set(STRUCTURED_INGESTION_ENDPOINT_PREF, fakeEndpoint);
+ sandbox.stub(Services.uuid, "generateUUID").returns(fakeUUID);
+ const feed = new TelemetryFeed();
+ const url = feed._generateStructuredIngestionEndpoint(
+ "testNameSpace",
+ "testPingType",
+ "1"
+ );
+
+ assert.equal(
+ url,
+ `${fakeEndpoint}/testNameSpace/testPingType/1/${fakeUUIDWithoutBraces}`
+ );
+ });
+ });
+ describe("#handleASRouterUserEvent", () => {
+ it("should call sendStructuredIngestionEvent on known pingTypes", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+ });
+ it("should call submitGleanPingForPing on known pingTypes when telemetry is enabled", async () => {
+ const data = {
+ action: "onboarding_user_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ instance._prefs.set(TELEMETRY_PREF, true);
+ sandbox.spy(
+ global.AboutWelcomeTelemetry.prototype,
+ "submitGleanPingForPing"
+ );
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(
+ global.AboutWelcomeTelemetry.prototype.submitGleanPingForPing
+ );
+ });
+ it("should console.error and not submit pings on unknown pingTypes", async () => {
+ const data = {
+ action: "unknown_event",
+ event: "IMPRESSION",
+ message_id: "12345",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleASRouterUserEvent({ data });
+
+ assert.calledOnce(global.console.error);
+ assert.notCalled(instance.sendStructuredIngestionEvent);
+ });
+ });
+ describe("#isInCFRCohort", () => {
+ it("should return false if there is no CFR experiment registered", () => {
+ assert.ok(!instance.isInCFRCohort);
+ });
+ it("should return true if there is a CFR experiment registered", () => {
+ sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({
+ slug: "SOME-CFR-EXP",
+ });
+
+ assert.ok(instance.isInCFRCohort);
+ assert.propertyVal(
+ ExperimentAPI.getExperimentMetaData.firstCall.args[0],
+ "featureId",
+ "cfr"
+ );
+ });
+ });
+ describe("#handleTopSitesSponsoredImpressionStats", () => {
+ it("should call sendStructuredIngestionEvent on an impression event", async () => {
+ const data = {
+ type: "impression",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+ sandbox.spy(Services.telemetry, "keyedScalarAdd");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Scalar should be added
+ assert.calledOnce(Services.telemetry.keyedScalarAdd);
+ assert.calledWith(
+ Services.telemetry.keyedScalarAdd,
+ "contextual.services.topsites.impression",
+ "newtab_1",
+ 1
+ );
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+
+ const { args } = instance.sendStructuredIngestionEvent.firstCall;
+ // payload
+ assert.deepEqual(args[0], {
+ context_id: FAKE_UUID,
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ });
+ // namespace
+ assert.equal(args[1], "contextual-services");
+ // docType
+ assert.equal(args[2], "topsites-impression");
+ // version
+ assert.equal(args[3], "1");
+ });
+ it("should call sendStructuredIngestionEvent on a click event", async () => {
+ const data = {
+ type: "click",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+ sandbox.spy(Services.telemetry, "keyedScalarAdd");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Scalar should be added
+ assert.calledOnce(Services.telemetry.keyedScalarAdd);
+ assert.calledWith(
+ Services.telemetry.keyedScalarAdd,
+ "contextual.services.topsites.click",
+ "newtab_1",
+ 1
+ );
+
+ assert.calledOnce(instance.sendStructuredIngestionEvent);
+
+ const { args } = instance.sendStructuredIngestionEvent.firstCall;
+ // payload
+ assert.deepEqual(args[0], {
+ context_id: FAKE_UUID,
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ });
+ // namespace
+ assert.equal(args[1], "contextual-services");
+ // docType
+ assert.equal(args[2], "topsites-click");
+ // version
+ assert.equal(args[3], "1");
+ });
+ it("should record a Glean topsites.impression event on an impression event", async () => {
+ const data = {
+ type: "impression",
+ tile_id: 42,
+ source: "newtab",
+ position: 1,
+ reporting_url: "https://test.reporting.net/",
+ advertiser: "adnoid ads",
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.impression, "record");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Event should be recorded
+ assert.calledOnce(Glean.topsites.impression.record);
+ assert.calledWith(Glean.topsites.impression.record, {
+ advertiser_name: "adnoid ads",
+ tile_id: "42",
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: 1,
+ });
+ });
+ it("should record a Glean topsites.click event on a click event", async () => {
+ const data = {
+ type: "click",
+ advertiser: "test advertiser",
+ tile_id: 42,
+ source: "newtab",
+ position: 0,
+ reporting_url: "https://test.reporting.net/",
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.click, "record");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ // Event should be recorded
+ assert.calledOnce(Glean.topsites.click.record);
+ assert.calledWith(Glean.topsites.click.record, {
+ advertiser_name: "test advertiser",
+ tile_id: "42",
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: 0,
+ });
+ });
+ it("should console.error on unknown pingTypes", async () => {
+ const data = { type: "unknown_type" };
+ instance = new TelemetryFeed();
+ sandbox.spy(instance, "sendStructuredIngestionEvent");
+
+ await instance.handleTopSitesSponsoredImpressionStats({ data });
+
+ assert.calledOnce(global.console.error);
+ assert.notCalled(instance.sendStructuredIngestionEvent);
+ });
+ });
+ describe("#handleTopSitesOrganicImpressionStats", () => {
+ it("should record a Glean topsites.impression event on an impression event", async () => {
+ const data = {
+ type: "impression",
+ source: "newtab",
+ position: 0,
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.impression, "record");
+
+ await instance.handleTopSitesOrganicImpressionStats({ data });
+
+ assert.calledOnce(Glean.topsites.impression.record);
+ assert.calledWith(Glean.topsites.impression.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: 0,
+ });
+ });
+ it("should record a Glean topsites.click event on a click event", async () => {
+ const data = {
+ type: "click",
+ source: "newtab",
+ position: 0,
+ };
+ instance = new TelemetryFeed();
+ const session_id = "decafc0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.topsites.click, "record");
+
+ await instance.handleTopSitesOrganicImpressionStats({ data });
+
+ assert.calledOnce(Glean.topsites.click.record);
+ assert.calledWith(Glean.topsites.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: 0,
+ });
+ });
+ });
+ describe("#handleDiscoveryStreamUserEvent", () => {
+ it("correctly handles action with no `data`", () => {
+ const action = ac.DiscoveryStreamUserEvent();
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("correctly handles CLICK data with no value", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic: undefined,
+ });
+ });
+ it("correctly handles non-POPULAR_TOPICS CLICK data with no value", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("correctly handles CLICK data with non-POPULAR_TOPICS source", () => {
+ const topic = "atopic";
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ value: {
+ card_type: "topics_widget",
+ topic,
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic,
+ });
+ });
+ it("doesn't instrument a CLICK without a card_type", () => {
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "not-POPULAR_TOPICS",
+ value: {
+ card_type: "not spoc, organic, or topics_widget",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+ sandbox.spy(Glean.pocket.click, "record");
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.notCalled(Glean.pocket.topicClick.record);
+ assert.notCalled(Glean.pocket.click.record);
+ assert.notCalled(Glean.pocket.save.record);
+ });
+ it("instruments a popular topic click", () => {
+ const topic = "entertainment";
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ value: {
+ card_type: "topics_widget",
+ topic,
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.topicClick, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.topicClick.record);
+ assert.calledWith(Glean.pocket.topicClick.record, {
+ newtab_visit_id: session_id,
+ topic,
+ });
+ });
+ it("instruments an organic top stories click", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ action_position,
+ value: {
+ card_type: "organic",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.click, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.click.record);
+ assert.calledWith(Glean.pocket.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ it("instruments a sponsored top stories click", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ action_position,
+ value: {
+ card_type: "spoc",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.click, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.click.record);
+ assert.calledWith(Glean.pocket.click.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: action_position,
+ });
+ });
+ it("instruments a save of an organic top story", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ value: {
+ card_type: "organic",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ it("instruments a save of a sponsored top story", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ value: {
+ card_type: "spoc",
+ },
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: true,
+ position: action_position,
+ });
+ });
+ it("instruments a save of a sponsored top story, without `value`", () => {
+ const action_position = 42;
+ const action = ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ action_position,
+ });
+ instance = new TelemetryFeed();
+ const session_id = "c0ffee";
+ sandbox.stub(instance.sessions, "get").returns({ session_id });
+ sandbox.spy(Glean.pocket.save, "record");
+
+ instance.handleDiscoveryStreamUserEvent(action);
+
+ assert.calledOnce(Glean.pocket.save.record);
+ assert.calledWith(Glean.pocket.save.record, {
+ newtab_visit_id: session_id,
+ is_sponsored: false,
+ position: action_position,
+ });
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js
new file mode 100644
index 0000000000..661a6b7b83
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js
@@ -0,0 +1,121 @@
+import { GlobalOverrider } from "test/unit/utils";
+import { TippyTopProvider } from "lib/TippyTopProvider.sys.mjs";
+
+describe("TippyTopProvider", () => {
+ let instance;
+ let globals;
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve([
+ {
+ domains: ["facebook.com"],
+ image_url: "images/facebook-com.png",
+ favicon_url: "images/facebook-com.png",
+ background_color: "#3b5998",
+ },
+ {
+ domains: ["gmail.com", "mail.google.com"],
+ image_url: "images/gmail-com.png",
+ favicon_url: "images/gmail-com.png",
+ background_color: "#000000",
+ },
+ ]),
+ });
+ instance = new TippyTopProvider();
+ await instance.init();
+ });
+ it("should provide an icon for facebook.com", () => {
+ const site = instance.processSite({ url: "https://facebook.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should provide an icon for www.facebook.com", () => {
+ const site = instance.processSite({ url: "https://www.facebook.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should not provide an icon for other.facebook.com", () => {
+ const site = instance.processSite({ url: "https://other.facebook.com" });
+ assert.isUndefined(site.tippyTopIcon);
+ });
+ it("should provide an icon for other.facebook.com with stripping", () => {
+ const site = instance.processSite(
+ { url: "https://other.facebook.com" },
+ "*"
+ );
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ });
+ it("should provide an icon for facebook.com/foobar", () => {
+ const site = instance.processSite({ url: "https://facebook.com/foobar" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png"
+ );
+ assert.equal(site.backgroundColor, "#3b5998");
+ });
+ it("should provide an icon for gmail.com", () => {
+ const site = instance.processSite({ url: "https://gmail.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(site.backgroundColor, "#000000");
+ });
+ it("should provide an icon for mail.google.com", () => {
+ const site = instance.processSite({ url: "https://mail.google.com" });
+ assert.equal(
+ site.tippyTopIcon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(
+ site.smallFavicon,
+ "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png"
+ );
+ assert.equal(site.backgroundColor, "#000000");
+ });
+ it("should handle garbage URLs gracefully", () => {
+ const site = instance.processSite({ url: "garbagejlfkdsa" });
+ assert.isUndefined(site.tippyTopIcon);
+ assert.isUndefined(site.backgroundColor);
+ });
+ it("should handle error when fetching and parsing manifest", async () => {
+ globals = new GlobalOverrider();
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.rejects("whaaaa");
+ instance = new TippyTopProvider();
+ await instance.init();
+ instance.processSite({ url: "https://facebook.com" });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
new file mode 100644
index 0000000000..12e70557f6
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -0,0 +1,649 @@
+import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
+import { _ToolbarPanelHub, ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+
+describe("ToolbarBadgeHub", () => {
+ let sandbox;
+ let instance;
+ let fakeAddImpression;
+ let fakeSendTelemetry;
+ let isBrowserPrivateStub;
+ let fxaMessage;
+ let whatsnewMessage;
+ let fakeElement;
+ let globals;
+ let everyWindowStub;
+ let clearTimeoutStub;
+ let setTimeoutStub;
+ let addObserverStub;
+ let removeObserverStub;
+ let getStringPrefStub;
+ let clearUserPrefStub;
+ let setStringPrefStub;
+ let requestIdleCallbackStub;
+ let fakeWindow;
+ beforeEach(async () => {
+ globals = new GlobalOverrider();
+ sandbox = sinon.createSandbox();
+ instance = new _ToolbarBadgeHub();
+ fakeAddImpression = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE");
+ whatsnewMessage = {
+ id: `WHATS_NEW_BADGE_71`,
+ template: "toolbar_badge",
+ content: {
+ delay: 1000,
+ target: "whats-new-menu-button",
+ action: { id: "show-whatsnew-button" },
+ badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" },
+ },
+ priority: 1,
+ trigger: { id: "toolbarBadgeUpdate" },
+ frequency: {
+ // Makes it so that we track impressions for this message while at the
+ // same time it can have unlimited impressions
+ lifetime: Infinity,
+ },
+ // Never saw this message or saw it in the past 4 days or more recent
+ targeting: `isWhatsNewPanelEnabled &&
+ (!messageImpressions['WHATS_NEW_BADGE_71'] ||
+ (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 &&
+ currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`,
+ };
+ fakeElement = {
+ classList: {
+ add: sandbox.stub(),
+ remove: sandbox.stub(),
+ },
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ remove: sandbox.stub(),
+ appendChild: sandbox.stub(),
+ };
+ // Share the same element when selecting child nodes
+ fakeElement.querySelector.returns(fakeElement);
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ clearTimeoutStub = sandbox.stub();
+ setTimeoutStub = sandbox.stub();
+ fakeWindow = {
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ gBrowser: {
+ selectedBrowser: "browser",
+ },
+ },
+ };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getStringPrefStub = sandbox.stub();
+ clearUserPrefStub = sandbox.stub();
+ setStringPrefStub = sandbox.stub();
+ requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn());
+ globals.set({
+ ToolbarPanelHub,
+ requestIdleCallback: requestIdleCallbackStub,
+ EveryWindow: everyWindowStub,
+ PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub },
+ setTimeout: setTimeoutStub,
+ clearTimeout: clearTimeoutStub,
+ Services: {
+ wm: {
+ getMostRecentWindow: () => fakeWindow,
+ },
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getStringPref: getStringPrefStub,
+ clearUserPref: clearUserPrefStub,
+ setStringPref: setStringPrefStub,
+ },
+ },
+ });
+ });
+ afterEach(() => {
+ sandbox.restore();
+ globals.restore();
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ describe("#init", () => {
+ it("should make a single messageRequest on init", async () => {
+ sandbox.stub(instance, "messageRequest");
+ const waitForInitialized = sandbox.stub().resolves();
+
+ await instance.init(waitForInitialized, {});
+ await instance.init(waitForInitialized, {});
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+
+ instance.uninit();
+
+ await instance.init(waitForInitialized, {});
+
+ assert.calledTwice(instance.messageRequest);
+ });
+ it("should add a pref observer", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ assert.calledOnce(addObserverStub);
+ assert.calledWithExactly(
+ addObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("#uninit", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+ });
+ it("should clear any setTimeout cbs", async () => {
+ await instance.init(sandbox.stub().resolves(), {});
+
+ instance.state.showBadgeTimeoutId = 2;
+
+ instance.uninit();
+
+ assert.calledOnce(clearTimeoutStub);
+ assert.calledWithExactly(clearTimeoutStub, 2);
+ });
+ it("should remove the pref observer", () => {
+ instance.uninit();
+
+ assert.calledOnce(removeObserverStub);
+ assert.calledWithExactly(
+ removeObserverStub,
+ instance.prefs.WHATSNEW_TOOLBAR_PANEL,
+ instance
+ );
+ });
+ });
+ describe("messageRequest", () => {
+ let handleMessageRequestStub;
+ beforeEach(() => {
+ handleMessageRequestStub = sandbox.stub().returns(fxaMessage);
+ sandbox
+ .stub(instance, "_handleMessageRequest")
+ .value(handleMessageRequestStub);
+ sandbox.stub(instance, "registerBadgeNotificationListener");
+ });
+ it("should fetch a message with the provided trigger and template", async () => {
+ await instance.messageRequest({
+ triggerId: "trigger",
+ template: "template",
+ });
+
+ assert.calledOnce(handleMessageRequestStub);
+ assert.calledWithExactly(handleMessageRequestStub, {
+ triggerId: "trigger",
+ template: "template",
+ });
+ });
+ it("should call addToolbarNotification with browser window and message", async () => {
+ await instance.messageRequest("trigger");
+
+ assert.calledOnce(instance.registerBadgeNotificationListener);
+ assert.calledWithExactly(
+ instance.registerBadgeNotificationListener,
+ fxaMessage
+ );
+ });
+ it("shouldn't do anything if no message is provided", async () => {
+ handleMessageRequestStub.resolves(null);
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.notCalled(instance.registerBadgeNotificationListener);
+ });
+ it("should record telemetry events", async () => {
+ const startTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "start"
+ );
+ const finishTelemetryStopwatch = sandbox.stub(
+ global.TelemetryStopwatch,
+ "finish"
+ );
+ handleMessageRequestStub.returns(null);
+
+ await instance.messageRequest({ triggerId: "trigger" });
+
+ assert.calledOnce(startTelemetryStopwatch);
+ assert.calledWithExactly(
+ startTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ assert.calledOnce(finishTelemetryStopwatch);
+ assert.calledWithExactly(
+ finishTelemetryStopwatch,
+ "MS_MESSAGE_REQUEST_TIME_MS",
+ { triggerId: "trigger" }
+ );
+ });
+ });
+ describe("addToolbarNotification", () => {
+ let target;
+ let fakeDocument;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElement),
+ createElement: sandbox.stub().returns(fakeElement),
+ l10n: { setAttributes: sandbox.stub() },
+ };
+ target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("shouldn't do anything if target element is not found", () => {
+ fakeDocument.getElementById.returns(null);
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.notCalled(fakeElement.setAttribute);
+ });
+ it("should target the element specified in the message", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeDocument.getElementById);
+ assert.calledWithExactly(
+ fakeDocument.getElementById,
+ fxaMessage.content.target
+ );
+ });
+ it("should show a notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledOnce(fakeElement.setAttribute);
+ assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
+ assert.calledWithExactly(fakeElement.classList.add, "feature-callout");
+ });
+ it("should attach a cb on the notification", () => {
+ instance.addToolbarNotification(target, fxaMessage);
+
+ assert.calledTwice(fakeElement.addEventListener);
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeElement.addEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should execute actions if they exist", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.executeAction);
+ assert.calledWithExactly(instance.executeAction, {
+ ...whatsnewMessage.content.action,
+ message_id: whatsnewMessage.id,
+ });
+ });
+ it("should create a description element", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.createElement);
+ assert.calledWithExactly(fakeDocument.createElement, "span");
+ });
+ it("should set description id to element and to button", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "id",
+ "toolbarbutton-notification-description"
+ );
+ assert.calledWithExactly(
+ fakeElement.setAttribute,
+ "aria-labelledby",
+ `toolbarbutton-notification-description ${whatsnewMessage.content.target}`
+ );
+ });
+ it("should attach fluent id to description", () => {
+ sandbox.stub(instance, "executeAction");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(fakeDocument.l10n.setAttributes);
+ assert.calledWithExactly(
+ fakeDocument.l10n.setAttributes,
+ fakeElement,
+ whatsnewMessage.content.badgeDescription.string_id
+ );
+ });
+ it("should add an impression for the message", () => {
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance._addImpression);
+ assert.calledWithExactly(instance._addImpression, whatsnewMessage);
+ });
+ it("should send an impression ping", async () => {
+ sandbox.stub(instance, "sendUserEventTelemetry");
+ instance.addToolbarNotification(target, whatsnewMessage);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(
+ instance.sendUserEventTelemetry,
+ "IMPRESSION",
+ whatsnewMessage
+ );
+ });
+ });
+ describe("registerBadgeNotificationListener", () => {
+ let msg_no_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ sandbox.stub(instance, "addToolbarNotification").returns(fakeElement);
+ sandbox.stub(instance, "removeToolbarNotification");
+ msg_no_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 0,
+ },
+ };
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a callback that adds/removes the notification", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay);
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ instance.id,
+ sinon.match.func,
+ sinon.match.func
+ );
+
+ const [, initFn, uninitFn] =
+ everyWindowStub.registerCallback.firstCall.args;
+
+ initFn(window);
+ // Test that it doesn't try to add a second notification
+ initFn(window);
+
+ assert.calledOnce(instance.addToolbarNotification);
+ assert.calledWithExactly(
+ instance.addToolbarNotification,
+ window,
+ msg_no_delay
+ );
+
+ uninitFn(window);
+
+ assert.calledOnce(instance.removeToolbarNotification);
+ assert.calledWithExactly(instance.removeToolbarNotification, fakeElement);
+ });
+ it("should unregister notifications when forcing a badge via devtools", () => {
+ instance.registerBadgeNotificationListener(msg_no_delay, { force: true });
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should only call executeAction for 'update_action' messages", () => {
+ const stub = sandbox.stub(instance, "executeAction");
+ const updateActionMsg = { ...msg_no_delay, template: "update_action" };
+
+ instance.registerBadgeNotificationListener(updateActionMsg);
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ assert.calledOnce(stub);
+ });
+ });
+ describe("executeAction", () => {
+ let blockMessageByIdStub;
+ beforeEach(async () => {
+ blockMessageByIdStub = sandbox.stub();
+ await instance.init(sandbox.stub().resolves(), {
+ blockMessageById: blockMessageByIdStub,
+ });
+ });
+ it("should call ToolbarPanelHub.enableToolbarButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableToolbarButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ it("should call ToolbarPanelHub.enableAppmenuButton", () => {
+ const stub = sandbox.stub(
+ _ToolbarPanelHub.prototype,
+ "enableAppmenuButton"
+ );
+
+ instance.executeAction({ id: "show-whatsnew-button" });
+
+ assert.calledOnce(stub);
+ });
+ });
+ describe("removeToolbarNotification", () => {
+ it("should remove the notification", () => {
+ instance.removeToolbarNotification(fakeElement);
+
+ assert.calledThrice(fakeElement.removeAttribute);
+ assert.calledWithExactly(fakeElement.removeAttribute, "badged");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby");
+ assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby");
+ assert.calledOnce(fakeElement.classList.remove);
+ assert.calledWithExactly(fakeElement.classList.remove, "feature-callout");
+ assert.calledOnce(fakeElement.remove);
+ });
+ });
+ describe("removeAllNotifications", () => {
+ let blockMessageByIdStub;
+ let fakeEvent;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ blockMessageByIdStub = sandbox.stub();
+ sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub);
+ instance.state = { notification: { id: fxaMessage.id } };
+ fakeEvent = { target: { removeEventListener: sandbox.stub() } };
+ });
+ it("should call to block the message", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(blockMessageByIdStub);
+ assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id);
+ });
+ it("should remove the window listener", () => {
+ instance.removeAllNotifications();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
+ });
+ it("should ignore right mouse button (mousedown event)", () => {
+ fakeEvent.type = "mousedown";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore right mouse button (click event)", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 1; // not left click
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should ignore keypresses that are not meant to focus the target", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "\t"; // not enter
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.notCalled(fakeEvent.target.removeEventListener);
+ assert.notCalled(everyWindowStub.unregisterCallback);
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ it("should send telemetry", () => {
+ fakeEvent.type = "click";
+ fakeEvent.button = 0;
+ sandbox.stub(instance, "sendUserEventTelemetry");
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledOnce(instance.sendUserEventTelemetry);
+ assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", {
+ id: "FXA_ACCOUNTS_BADGE",
+ });
+ });
+ it("should remove the event listeners after succesfully focusing the element", () => {
+ fakeEvent.type = "keypress";
+ fakeEvent.key = "Enter";
+
+ instance.removeAllNotifications(fakeEvent);
+
+ assert.calledTwice(fakeEvent.target.removeEventListener);
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "mousedown",
+ instance.removeAllNotifications
+ );
+ assert.calledWithExactly(
+ fakeEvent.target.removeEventListener,
+ "keypress",
+ instance.removeAllNotifications
+ );
+ });
+ });
+ describe("message with delay", () => {
+ let msg_with_delay;
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ addImpression: fakeAddImpression,
+ });
+ msg_with_delay = {
+ ...fxaMessage,
+ content: {
+ ...fxaMessage.content,
+ delay: 500,
+ },
+ };
+ sandbox.stub(instance, "registerBadgeToAllWindows");
+ });
+ afterEach(() => {
+ instance.uninit();
+ });
+ it("should register a cb to fire after msg.content.delay ms", () => {
+ instance.registerBadgeNotificationListener(msg_with_delay);
+
+ assert.calledOnce(setTimeoutStub);
+ assert.calledWithExactly(
+ setTimeoutStub,
+ sinon.match.func,
+ msg_with_delay.content.delay
+ );
+
+ const [cb] = setTimeoutStub.firstCall.args;
+
+ assert.notCalled(instance.registerBadgeToAllWindows);
+
+ cb();
+
+ assert.calledOnce(instance.registerBadgeToAllWindows);
+ assert.calledWithExactly(
+ instance.registerBadgeToAllWindows,
+ msg_with_delay
+ );
+ // Delayed actions should be executed inside requestIdleCallback
+ assert.calledOnce(requestIdleCallbackStub);
+ });
+ });
+ describe("#sendUserEventTelemetry", () => {
+ beforeEach(async () => {
+ await instance.init(sandbox.stub().resolves(), {
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should check for private window and not send", () => {
+ isBrowserPrivateStub.returns(true);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.notCalled(instance._sendTelemetry);
+ });
+ it("should check for private window and send", () => {
+ isBrowserPrivateStub.returns(false);
+
+ instance.sendUserEventTelemetry("CLICK", { id: fxaMessage });
+
+ assert.calledOnce(fakeSendTelemetry);
+ const [ping] = instance._sendTelemetry.firstCall.args;
+ assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY");
+ assert.propertyVal(ping.data, "event", "CLICK");
+ });
+ });
+ describe("#observe", () => {
+ it("should make a message request when the whats new pref is changed", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL);
+
+ assert.calledOnce(instance.messageRequest);
+ assert.calledWithExactly(instance.messageRequest, {
+ template: "toolbar_badge",
+ triggerId: "toolbarBadgeUpdate",
+ });
+ });
+ it("should not react to other pref changes", () => {
+ sandbox.stub(instance, "messageRequest");
+
+ instance.observe("", "", "foo");
+
+ assert.notCalled(instance.messageRequest);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
new file mode 100644
index 0000000000..36fcc0cbe3
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js
@@ -0,0 +1,934 @@
+import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
+import { GlobalOverrider } from "test/unit/utils";
+import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
+import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs";
+
+describe("ToolbarPanelHub", () => {
+ let globals;
+ let sandbox;
+ let instance;
+ let everyWindowStub;
+ let preferencesStub;
+ let fakeDocument;
+ let fakeWindow;
+ let fakeElementById;
+ let fakeElementByTagName;
+ let createdCustomElements = [];
+ let eventListeners = {};
+ let addObserverStub;
+ let removeObserverStub;
+ let getBoolPrefStub;
+ let setBoolPrefStub;
+ let waitForInitializedStub;
+ let isBrowserPrivateStub;
+ let fakeSendTelemetry;
+ let getEarliestRecordedDateStub;
+ let getEventsByDateRangeStub;
+ let defaultSearchStub;
+ let scriptloaderStub;
+ let fakeRemoteL10n;
+ let getViewNodeStub;
+
+ beforeEach(async () => {
+ sandbox = sinon.createSandbox();
+ globals = new GlobalOverrider();
+ instance = new _ToolbarPanelHub();
+ waitForInitializedStub = sandbox.stub().resolves();
+ fakeElementById = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeElementByTagName = {
+ setAttribute: sandbox.stub(),
+ removeAttribute: sandbox.stub(),
+ querySelector: sandbox.stub().returns(null),
+ querySelectorAll: sandbox.stub().returns([]),
+ appendChild: sandbox.stub(),
+ addEventListener: sandbox.stub(),
+ hasAttribute: sandbox.stub(),
+ toggleAttribute: sandbox.stub(),
+ remove: sandbox.stub(),
+ removeChild: sandbox.stub(),
+ };
+ fakeDocument = {
+ getElementById: sandbox.stub().returns(fakeElementById),
+ getElementsByTagName: sandbox.stub().returns(fakeElementByTagName),
+ querySelector: sandbox.stub().returns({}),
+ createElement: tagName => {
+ const element = {
+ tagName,
+ classList: {},
+ addEventListener: (ev, fn) => {
+ eventListeners[ev] = fn;
+ },
+ appendChild: sandbox.stub(),
+ setAttribute: sandbox.stub(),
+ textContent: "",
+ };
+ element.classList.add = sandbox.stub();
+ element.classList.includes = className =>
+ element.classList.add.firstCall.args[0] === className;
+ createdCustomElements.push(element);
+ return element;
+ },
+ l10n: {
+ translateElements: sandbox.stub(),
+ translateFragment: sandbox.stub(),
+ formatMessages: sandbox.stub().resolves([{}]),
+ setAttributes: sandbox.stub(),
+ },
+ };
+ fakeWindow = {
+ // eslint-disable-next-line object-shorthand
+ DocumentFragment: function () {
+ return fakeElementById;
+ },
+ document: fakeDocument,
+ browser: {
+ ownerDocument: fakeDocument,
+ },
+ MozXULElement: { insertFTLIfNeeded: sandbox.stub() },
+ ownerGlobal: {
+ openLinkIn: sandbox.stub(),
+ gBrowser: "gBrowser",
+ },
+ PanelUI: {
+ panel: fakeElementById,
+ whatsNewPanel: fakeElementById,
+ },
+ customElements: { get: sandbox.stub() },
+ };
+ everyWindowStub = {
+ registerCallback: sandbox.stub(),
+ unregisterCallback: sandbox.stub(),
+ };
+ preferencesStub = {
+ get: sandbox.stub(),
+ set: sandbox.stub(),
+ };
+ scriptloaderStub = { loadSubScript: sandbox.stub() };
+ addObserverStub = sandbox.stub();
+ removeObserverStub = sandbox.stub();
+ getBoolPrefStub = sandbox.stub();
+ setBoolPrefStub = sandbox.stub();
+ fakeSendTelemetry = sandbox.stub();
+ isBrowserPrivateStub = sandbox.stub();
+ getEarliestRecordedDateStub = sandbox.stub().returns(
+ // A random date that's not the current timestamp
+ new Date() - 500
+ );
+ getEventsByDateRangeStub = sandbox.stub().returns([]);
+ getViewNodeStub = sandbox.stub().returns(fakeElementById);
+ defaultSearchStub = { defaultEngine: { name: "DDG" } };
+ fakeRemoteL10n = {
+ l10n: {},
+ reloadL10n: sandbox.stub(),
+ createElement: sandbox
+ .stub()
+ .callsFake((doc, el) => fakeDocument.createElement(el)),
+ };
+ globals.set({
+ EveryWindow: everyWindowStub,
+ Services: {
+ ...Services,
+ prefs: {
+ addObserver: addObserverStub,
+ removeObserver: removeObserverStub,
+ getBoolPref: getBoolPrefStub,
+ setBoolPref: setBoolPrefStub,
+ },
+ search: defaultSearchStub,
+ scriptloader: scriptloaderStub,
+ },
+ PrivateBrowsingUtils: {
+ isBrowserPrivate: isBrowserPrivateStub,
+ },
+ Preferences: preferencesStub,
+ TrackingDBService: {
+ getEarliestRecordedDate: getEarliestRecordedDateStub,
+ getEventsByDateRange: getEventsByDateRangeStub,
+ },
+ SpecialMessageActions: {
+ handleAction: sandbox.stub(),
+ },
+ RemoteL10n: fakeRemoteL10n,
+ PanelMultiView: {
+ getViewNode: getViewNodeStub,
+ },
+ });
+ });
+ afterEach(() => {
+ instance.uninit();
+ sandbox.restore();
+ globals.restore();
+ eventListeners = {};
+ createdCustomElements = [];
+ });
+ it("should create an instance", () => {
+ assert.ok(instance);
+ });
+ it("should enableAppmenuButton() on init() just once", async () => {
+ instance.enableAppmenuButton = sandbox.stub();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledOnce(instance.enableAppmenuButton);
+
+ instance.uninit();
+
+ await instance.init(waitForInitializedStub, { getMessages: () => {} });
+
+ assert.calledTwice(instance.enableAppmenuButton);
+ });
+ it("should unregisterCallback on uninit()", () => {
+ instance.uninit();
+ assert.calledTwice(everyWindowStub.unregisterCallback);
+ });
+ describe("#maybeLoadCustomElement", () => {
+ it("should not load customElements a second time", () => {
+ instance.maybeLoadCustomElement({ customElements: new Map() });
+ instance.maybeLoadCustomElement({
+ customElements: new Map([["remote-text", true]]),
+ });
+
+ assert.calledOnce(scriptloaderStub.loadSubScript);
+ });
+ });
+ describe("#toggleWhatsNewPref", () => {
+ it("should call Preferences.set() with the opposite value", () => {
+ let checkbox = {};
+ let event = { target: checkbox };
+ // checkbox starts false
+ checkbox.checked = false;
+
+ // toggling the checkbox to set the value to true;
+ // Preferences.set() gets called before the checkbox changes,
+ // so we have to call it with the opposite value.
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(preferencesStub.set);
+ assert.calledWith(
+ preferencesStub.set,
+ "browser.messaging-system.whatsNewPanel.enabled",
+ !checkbox.checked
+ );
+ });
+ it("should report telemetry with the opposite value", () => {
+ let sendUserEventTelemetryStub = sandbox.stub(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ let event = {
+ target: { checked: true, ownerGlobal: fakeWindow },
+ };
+
+ instance.toggleWhatsNewPref(event);
+
+ assert.calledOnce(sendUserEventTelemetryStub);
+ const { args } = sendUserEventTelemetryStub.firstCall;
+ assert.equal(args[1], "WNP_PREF_TOGGLE");
+ assert.propertyVal(args[3].value, "prefValue", false);
+ });
+ });
+ describe("#enableAppmenuButton", () => {
+ it("should registerCallback on enableAppmenuButton() if there are messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "appMenu-whatsnew-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => {
+ instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableAppmenuButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("#disableAppmenuButton", () => {
+ it("should call the unregisterCallback", () => {
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ instance.disableAppmenuButton();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "appMenu-whatsnew-button"
+ );
+ });
+ });
+ describe("#enableToolbarButton", () => {
+ it("should registerCallback on enableToolbarButton if messages.length", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([{}, {}]),
+ });
+ // init calls `enableAppmenuButton`
+ everyWindowStub.registerCallback.resetHistory();
+
+ await instance.enableToolbarButton();
+
+ assert.calledOnce(everyWindowStub.registerCallback);
+ assert.calledWithExactly(
+ everyWindowStub.registerCallback,
+ "whats-new-menu-button",
+ sinon.match.func,
+ sinon.match.func
+ );
+ });
+ it("should not registerCallback on enableToolbarButton if no messages", async () => {
+ await instance.init(waitForInitializedStub, {
+ getMessages: sandbox.stub().resolves([]),
+ });
+
+ await instance.enableToolbarButton();
+
+ assert.notCalled(everyWindowStub.registerCallback);
+ });
+ });
+ describe("Show/Hide functions", () => {
+ it("should unhide appmenu button on _showAppmenuButton()", async () => {
+ await instance._showAppmenuButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide appmenu button on _hideAppmenuButton()", () => {
+ instance._hideAppmenuButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ it("should not do anything if the window is closed", () => {
+ instance._hideAppmenuButton(fakeWindow, true);
+ assert.notCalled(global.PanelMultiView.getViewNode);
+ });
+ it("should not throw if the element does not exist", () => {
+ let fn = instance._hideAppmenuButton.bind(null, {
+ browser: { ownerDocument: {} },
+ });
+ getViewNodeStub.returns(undefined);
+ assert.doesNotThrow(fn);
+ });
+ it("should unhide toolbar button on _showToolbarButton()", async () => {
+ await instance._showToolbarButton(fakeWindow);
+
+ assert.equal(fakeElementById.hidden, false);
+ });
+ it("should hide toolbar button on _hideToolbarButton()", () => {
+ instance._hideToolbarButton(fakeWindow);
+ assert.equal(fakeElementById.hidden, true);
+ });
+ });
+ describe("#renderMessages", () => {
+ let getMessagesStub;
+ beforeEach(() => {
+ getMessagesStub = sandbox.stub();
+ instance.init(waitForInitializedStub, {
+ getMessages: getMessagesStub,
+ sendTelemetry: fakeSendTelemetry,
+ });
+ });
+ it("should have correct state", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.propertyVal(instance.state.contentArguments, "trackerCount", 4);
+ assert.propertyVal(
+ instance.state.contentArguments,
+ "fingerprinterCount",
+ 3
+ );
+ });
+ it("should render messages to the panel on renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ messages[0].content.link_text = { string_id: "link_text_id" };
+
+ getMessagesStub.returns(messages);
+ const ev1 = sandbox.stub();
+ ev1.withArgs("type").returns(1); // tracker
+ ev1.withArgs("count").returns(4);
+ const ev2 = sandbox.stub();
+ ev2.withArgs("type").returns(4); // fingerprinter
+ ev2.withArgs("count").returns(3);
+ getEventsByDateRangeStub.returns([
+ { getResultByName: ev1 },
+ { getResultByName: ev2 },
+ ]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ for (let message of messages) {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ );
+ if (message.content.layout === "tracking-protections") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-subtitle"
+ )
+ );
+ }
+ if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") {
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) => el === "h2" && args.content === 3
+ )
+ );
+ }
+ assert.ok(
+ fakeRemoteL10n.createElement.args.find(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-content"
+ )
+ );
+ }
+ // Call the click handler to make coverage happy.
+ eventListeners.mouseup();
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ });
+ it("should clear previous messages on 2nd renderMessages()", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const removeStub = sandbox.stub();
+ fakeElementById.querySelectorAll.onCall(0).returns([]);
+ fakeElementById.querySelectorAll
+ .onCall(1)
+ .returns([{ remove: removeStub }, { remove: removeStub }]);
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledTwice(removeStub);
+ });
+ it("should sort based on order field value", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m =>
+ m.template === "whatsnew_panel_message" &&
+ m.content.published_date === 1560969794394
+ );
+
+ messages.forEach(m => (m.content.title = m.order));
+
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ // Select the title elements that are supposed to be set to the same
+ // value as the `order` field of the message
+ const titleEls = fakeRemoteL10n.createElement.args
+ .filter(
+ ([doc, el, args]) =>
+ args && args.classList === "whatsNew-message-title"
+ )
+ .map(([doc, el, args]) => args.content);
+ assert.deepEqual(titleEls, [1, 2, 3]);
+ });
+ it("should accept string for image attributes", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const imageEl = createdCustomElements.find(el => el.tagName === "img");
+ assert.calledOnce(imageEl.setAttribute);
+ assert.calledWithExactly(
+ imageEl.setAttribute,
+ "alt",
+ "Firefox Send Logo"
+ );
+ });
+ it("should set state values as data-attribute", async () => {
+ const message = (await PanelTestProvider.getMessages()).find(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([message]);
+ instance.state.contentArguments = { foo: "foo", bar: "bar" };
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const [, , args] = fakeRemoteL10n.createElement.args.find(
+ ([doc, el, elArgs]) => elArgs && elArgs.attributes
+ );
+ assert.ok(args);
+ // Currently this.state.contentArguments has 8 different entries
+ assert.lengthOf(Object.keys(args.attributes), 8);
+ assert.equal(
+ args.attributes.searchEngineName,
+ defaultSearchStub.defaultEngine.name
+ );
+ });
+ it("should only render unique dates (no duplicates)", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ const uniqueDates = [
+ ...new Set(messages.map(m => m.content.published_date)),
+ ];
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const dateElements = fakeRemoteL10n.createElement.args.filter(
+ ([doc, el, args]) =>
+ el === "p" && args.classList === "whatsNew-message-date"
+ );
+ assert.lengthOf(dateElements, uniqueDates.length);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+ fakeDocument.getElementById
+ .withArgs("customizationui-widget-panel")
+ .returns(null);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.notCalled(fakeElementById.addEventListener);
+ });
+ it("should attach doCommand cbs that handle user actions", async () => {
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ const messageEl = createdCustomElements.find(
+ el =>
+ el.tagName === "div" && el.classList.includes("whatsNew-message-body")
+ );
+ const anchorEl = createdCustomElements.find(el => el.tagName === "a");
+
+ assert.notCalled(global.SpecialMessageActions.handleAction);
+
+ messageEl.doCommand();
+ anchorEl.doCommand();
+
+ assert.calledTwice(global.SpecialMessageActions.handleAction);
+ });
+ it("should listen for panelhidden and remove the toolbar button", async () => {
+ getMessagesStub.returns([]);
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(fakeElementById.addEventListener);
+ assert.calledWithExactly(
+ fakeElementById.addEventListener,
+ "popuphidden",
+ sinon.match.func,
+ {
+ once: true,
+ }
+ );
+ const [, cb] = fakeElementById.addEventListener.firstCall.args;
+
+ assert.notCalled(everyWindowStub.unregisterCallback);
+
+ cb();
+
+ assert.calledOnce(everyWindowStub.unregisterCallback);
+ assert.calledWithExactly(
+ everyWindowStub.unregisterCallback,
+ "whats-new-menu-button"
+ );
+ });
+ describe("#IMPRESSION", () => {
+ it("should dispatch a IMPRESSION for messages", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+ assert.propertyVal(
+ spy.firstCall.args[2],
+ "id",
+ messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",")
+ );
+ });
+ it("should dispatch a CLICK for clicking a message", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ // Force to render the message
+ fakeElementById.querySelector.returns(null);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.returns([messages[0]]);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledOnce(fakeSendTelemetry);
+
+ spy.resetHistory();
+
+ // Message click event listener cb
+ eventListeners.mouseup();
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]);
+ });
+ it("should dispatch a IMPRESSION with toolbar_dropdown", async () => {
+ // means panel is triggered from the toolbar button
+ fakeElementById.hasAttribute.returns(true);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "toolbar_dropdown",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "toolbar_dropdown",
+ });
+ });
+ it("should dispatch a IMPRESSION with application_menu", async () => {
+ // means panel is triggered as a subview in the application menu
+ fakeElementById.hasAttribute.returns(false);
+ const messages = (await PanelTestProvider.getMessages()).filter(
+ m => m.template === "whatsnew_panel_message"
+ );
+ getMessagesStub.resolves(messages);
+ const spy = sandbox.spy(instance, "sendUserEventTelemetry");
+ const panelPingId = messages
+ .map(({ id }) => id)
+ .sort()
+ .join(",");
+
+ await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
+
+ assert.calledOnce(spy);
+ assert.calledWithExactly(
+ spy,
+ fakeWindow,
+ "IMPRESSION",
+ {
+ id: panelPingId,
+ },
+ {
+ value: {
+ view: "application_menu",
+ },
+ }
+ );
+ assert.calledOnce(fakeSendTelemetry);
+ const {
+ args: [dispatchPayload],
+ } = fakeSendTelemetry.lastCall;
+ assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY");
+ assert.propertyVal(dispatchPayload.data, "message_id", panelPingId);
+ assert.deepEqual(dispatchPayload.data.event_context, {
+ view: "application_menu",
+ });
+ });
+ });
+ describe("#forceShowMessage", () => {
+ const panelSelector = "PanelUI-whatsNew-message-container";
+ let removeMessagesSpy;
+ let renderMessagesStub;
+ let addEventListenerStub;
+ let messages;
+ let browser;
+ beforeEach(async () => {
+ messages = (await PanelTestProvider.getMessages()).find(
+ m => m.id === "WHATS_NEW_70_1"
+ );
+ removeMessagesSpy = sandbox.spy(instance, "removeMessages");
+ renderMessagesStub = sandbox.spy(instance, "renderMessages");
+ addEventListenerStub = fakeElementById.addEventListener;
+ browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument };
+ fakeElementById.querySelectorAll.returns([fakeElementById]);
+ });
+ it("should call removeMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should call renderMessages when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(renderMessagesStub);
+ assert.calledWithExactly(
+ renderMessagesStub,
+ fakeWindow,
+ fakeDocument,
+ panelSelector,
+ {
+ force: true,
+ messages: Array.isArray(messages) ? messages : [messages],
+ }
+ );
+ });
+ it("should cleanup after the panel is hidden when forcing a message to show", () => {
+ instance.forceShowMessage(browser, messages);
+
+ assert.calledOnce(addEventListenerStub);
+ assert.calledWithExactly(
+ addEventListenerStub,
+ "popuphidden",
+ sinon.match.func
+ );
+
+ const [, cb] = addEventListenerStub.firstCall.args;
+ // Reset the call count from the first `forceShowMessage` call
+ removeMessagesSpy.resetHistory();
+ cb({ target: { ownerGlobal: fakeWindow } });
+
+ assert.calledOnce(removeMessagesSpy);
+ assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ it("should exit gracefully if called before a browser exists", () => {
+ instance.forceShowMessage(null, messages);
+ assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector);
+ });
+ });
+ });
+ describe("#insertProtectionPanelMessage", () => {
+ const fakeInsert = () =>
+ instance.insertProtectionPanelMessage({
+ target: { ownerGlobal: fakeWindow, ownerDocument: fakeDocument },
+ });
+ let getMessagesStub;
+ beforeEach(async () => {
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ getMessagesStub = sandbox
+ .stub()
+ .resolves(
+ onboardingMsgs.find(msg => msg.template === "protections_panel")
+ );
+ await instance.init(waitForInitializedStub, {
+ sendTelemetry: fakeSendTelemetry,
+ getMessages: getMessagesStub,
+ });
+ });
+ it("should remember it showed", async () => {
+ await fakeInsert();
+
+ assert.calledWithExactly(
+ setBoolPrefStub,
+ "browser.protections_panel.infoMessage.seen",
+ true
+ );
+ });
+ it("should toggle/expand when default collapsed/disabled", async () => {
+ fakeElementById.hasAttribute.returns(true);
+
+ await fakeInsert();
+
+ assert.calledThrice(fakeElementById.toggleAttribute);
+ });
+ it("should toggle again when popup hides", async () => {
+ fakeElementById.addEventListener.callsArg(1);
+
+ await fakeInsert();
+
+ assert.callCount(fakeElementById.toggleAttribute, 6);
+ });
+ it("should open link on click (separate link element)", async () => {
+ const sendTelemetryStub = sandbox.stub(
+ instance,
+ "sendUserEventTelemetry"
+ );
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+
+ await fakeInsert();
+
+ assert.calledOnce(sendTelemetryStub);
+ assert.calledWithExactly(
+ sendTelemetryStub,
+ fakeWindow,
+ "IMPRESSION",
+ msg
+ );
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: sinon.match.string,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should format the url", async () => {
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .returns("formattedURL");
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, msg.content.cta_url);
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: "formattedURL",
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should report format url errors", async () => {
+ const stub = sandbox
+ .stub(global.Services.urlFormatter, "formatURL")
+ .throws();
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+ sandbox.spy(global.console, "error");
+
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(stub);
+ assert.calledOnce(global.console.error);
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: msg.content.cta_url,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should open link on click (directly attached to the message)", async () => {
+ const onboardingMsgs =
+ await OnboardingMessageProvider.getUntranslatedMessages();
+ const msg = onboardingMsgs.find(m => m.template === "protections_panel");
+ getMessagesStub.resolves({
+ ...msg,
+ content: { ...msg.content, link_text: null },
+ });
+ await fakeInsert();
+
+ eventListeners.mouseup();
+
+ assert.calledOnce(global.SpecialMessageActions.handleAction);
+ assert.calledWithExactly(
+ global.SpecialMessageActions.handleAction,
+ {
+ type: "OPEN_URL",
+ data: {
+ args: sinon.match.string,
+ where: "tabshifted",
+ },
+ },
+ fakeWindow.browser
+ );
+ });
+ it("should handle user actions from mouseup and keyup", async () => {
+ await fakeInsert();
+
+ eventListeners.mouseup();
+ eventListeners.keyup({ key: "Enter" });
+ eventListeners.keyup({ key: " " });
+ assert.calledThrice(global.SpecialMessageActions.handleAction);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js
new file mode 100644
index 0000000000..a173c16cde
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js
@@ -0,0 +1,3020 @@
+"use strict";
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils";
+import {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+} from "common/Reducers.sys.mjs";
+import { getDefaultOptions } from "lib/ActivityStreamStorage.jsm";
+import injector from "inject!lib/TopSitesFeed.jsm";
+import { Screenshots } from "lib/Screenshots.jsm";
+import { LinksCache } from "lib/LinksCache.sys.mjs";
+
+const FAKE_FAVICON = "data987";
+const FAKE_FAVICON_SIZE = 128;
+const FAKE_FRECENCY = 200;
+const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW)
+ .fill(null)
+ .map((v, i) => ({
+ frecency: FAKE_FRECENCY,
+ url: `http://www.site${i}.com`,
+ }));
+const FAKE_SCREENSHOT = "data123";
+const SEARCH_SHORTCUTS_EXPERIMENT_PREF = "improvesearch.topSiteSearchShortcuts";
+const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF =
+ "improvesearch.topSiteSearchShortcuts.searchEngines";
+const SEARCH_SHORTCUTS_HAVE_PINNED_PREF =
+ "improvesearch.topSiteSearchShortcuts.havePinned";
+const SHOWN_ON_NEWTAB_PREF = "feeds.topsites";
+const SHOW_SPONSORED_PREF = "showSponsoredTopSites";
+const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
+const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting";
+const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles";
+const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor";
+const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch";
+
+function FakeTippyTopProvider() {}
+FakeTippyTopProvider.prototype = {
+ async init() {
+ this.initialized = true;
+ },
+ processSite(site) {
+ return site;
+ },
+};
+
+describe("Top Sites Feed", () => {
+ let TopSitesFeed;
+ let DEFAULT_TOP_SITES;
+ let feed;
+ let globals;
+ let sandbox;
+ let links;
+ let fakeNewTabUtils;
+ let fakeScreenshot;
+ let filterAdultStub;
+ let shortURLStub;
+ let fakePageThumbs;
+ let fetchStub;
+ let fakeNimbusFeatures;
+ let fakeSampling;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ fakeNewTabUtils = {
+ blockedLinks: {
+ links: [],
+ isBlocked: () => false,
+ unblock: sandbox.spy(),
+ },
+ activityStreamLinks: {
+ getTopSites: sandbox.spy(() => Promise.resolve(links)),
+ },
+ activityStreamProvider: {
+ _addFavicons: sandbox.spy(l =>
+ Promise.resolve(
+ l.map(link => {
+ link.favicon = FAKE_FAVICON;
+ link.faviconSize = FAKE_FAVICON_SIZE;
+ return link;
+ })
+ )
+ ),
+ _faviconBytesToDataURI: sandbox.spy(),
+ },
+ pinnedLinks: {
+ links: [],
+ isPinned: () => false,
+ pin: sandbox.spy(),
+ unpin: sandbox.spy(),
+ },
+ };
+ fakeScreenshot = {
+ getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)),
+ maybeCacheScreenshot: sandbox.spy(Screenshots.maybeCacheScreenshot),
+ _shouldGetScreenshots: sinon.stub().returns(true),
+ };
+ filterAdultStub = {
+ filter: sinon.stub().returnsArg(0),
+ };
+ shortURLStub = sinon
+ .stub()
+ .callsFake(site =>
+ site.url.replace(/(.com|.ca)/, "").replace("https://", "")
+ );
+ const fakeDedupe = function () {};
+ fakePageThumbs = {
+ addExpirationFilter: sinon.stub(),
+ removeExpirationFilter: sinon.stub(),
+ };
+ fakeNimbusFeatures = {
+ newtab: {
+ getVariable: sinon.stub(),
+ onUpdate: sinon.stub(),
+ offUpdate: sinon.stub(),
+ },
+ pocketNewtab: {
+ getVariable: sinon.stub(),
+ },
+ };
+ fakeSampling = {
+ ratioSample: sinon.stub(),
+ };
+ globals.set({
+ PageThumbs: fakePageThumbs,
+ NewTabUtils: fakeNewTabUtils,
+ gFilterAdultEnabled: false,
+ NimbusFeatures: fakeNimbusFeatures,
+ LinksCache,
+ FilterAdult: filterAdultStub,
+ Screenshots: fakeScreenshot,
+ Sampling: fakeSampling,
+ });
+ sandbox.spy(global.XPCOMUtils, "defineLazyGetter");
+ FAKE_GLOBAL_PREFS.set("default.sites", "https://foo.com/");
+ ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "common/Dedupe.jsm": { Dedupe: fakeDedupe },
+ "common/Reducers.jsm": {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+ },
+ "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ "lib/TippyTopProvider.sys.mjs": {
+ TippyTopProvider: FakeTippyTopProvider,
+ },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/ActivityStreamStorage.jsm": {
+ ActivityStreamStorage: function Fake() {},
+ getDefaultOptions,
+ },
+ }));
+ feed = new TopSitesFeed();
+ const storage = {
+ init: sandbox.stub().resolves(),
+ get: sandbox.stub().resolves(),
+ set: sandbox.stub().resolves(),
+ };
+ // Setup for tests that don't call `init` but require feed.storage
+ feed._storage = storage;
+ feed.store = {
+ dispatch: sinon.spy(),
+ getState() {
+ return this.state;
+ },
+ state: {
+ Prefs: { values: { topSitesRows: 2 } },
+ TopSites: { rows: Array(12).fill("site") },
+ },
+ dbStorage: { getDbTable: sandbox.stub().returns(storage) },
+ };
+ feed.dedupe.group = (...sites) => sites;
+ links = FAKE_LINKS;
+ // Turn off the search shortcuts experiment by default for other tests
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google,amazon";
+ });
+ afterEach(() => {
+ globals.restore();
+ sandbox.restore();
+ });
+
+ function stubFaviconsToUseScreenshots() {
+ fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub();
+ }
+
+ describe("#constructor", () => {
+ it("should defineLazyGetter for log, contextId, and _currentSearchHostname", () => {
+ assert.calledThrice(global.XPCOMUtils.defineLazyGetter);
+
+ let spyCall = global.XPCOMUtils.defineLazyGetter.getCall(0);
+ assert.ok(spyCall.calledWith(sinon.match.any, "log", sinon.match.func));
+
+ spyCall = global.XPCOMUtils.defineLazyGetter.getCall(1);
+ assert.ok(
+ spyCall.calledWith(sinon.match.any, "contextId", sinon.match.func)
+ );
+
+ spyCall = global.XPCOMUtils.defineLazyGetter.getCall(2);
+ assert.ok(
+ spyCall.calledWith(feed, "_currentSearchHostname", sinon.match.func)
+ );
+ });
+ });
+
+ describe("#refreshDefaults", () => {
+ it("should add defaults on PREFS_INITIAL_VALUES", () => {
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "https://foo.com" },
+ });
+
+ assert.isAbove(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should add defaults on default.sites PREF_CHANGED", () => {
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "default.sites", value: "https://foo.com" },
+ });
+
+ assert.isAbove(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should refresh on topSiteRows PREF_CHANGED", () => {
+ feed.refresh = sinon.spy();
+ feed.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } });
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should have default sites with .isDefault = true", () => {
+ feed.refreshDefaults("https://foo.com");
+
+ DEFAULT_TOP_SITES.forEach(link =>
+ assert.propertyVal(link, "isDefault", true)
+ );
+ });
+ it("should have default sites with appropriate hostname", () => {
+ feed.refreshDefaults("https://foo.com");
+
+ DEFAULT_TOP_SITES.forEach(link =>
+ assert.propertyVal(link, "hostname", shortURLStub(link))
+ );
+ });
+ it("should add no defaults on empty pref", () => {
+ feed.refreshDefaults("");
+
+ assert.equal(DEFAULT_TOP_SITES.length, 0);
+ });
+ it("should clear defaults", () => {
+ feed.refreshDefaults("https://foo.com");
+ feed.refreshDefaults("");
+
+ assert.equal(DEFAULT_TOP_SITES.length, 0);
+ });
+ });
+ describe("#filterForThumbnailExpiration", () => {
+ it("should pass rows.urls to the callback provided", () => {
+ const rows = [
+ { url: "foo.com" },
+ { url: "bar.com", customScreenshotURL: "custom" },
+ ];
+ feed.store.state.TopSites = { rows };
+ const stub = sinon.stub();
+
+ feed.filterForThumbnailExpiration(stub);
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, ["foo.com", "bar.com", "custom"]);
+ });
+ });
+ describe("#getLinksWithDefaults", () => {
+ beforeEach(() => {
+ feed.refreshDefaults("https://foo.com");
+ });
+
+ describe("general", () => {
+ it("should get the links from NewTabUtils", async () => {
+ const result = await feed.getLinksWithDefaults();
+ const reference = links.map(site =>
+ Object.assign({}, site, {
+ hostname: shortURLStub(site),
+ typedBonus: true,
+ })
+ );
+
+ assert.deepEqual(result, reference);
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should indicate the links get typed bonus", async () => {
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(result[0], "typedBonus", true);
+ });
+ it("should filter out non-pinned adult sites", async () => {
+ filterAdultStub.filter = sinon.stub().returns([]);
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ // The stub filters out everything
+ assert.calledOnce(filterAdultStub.filter);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ });
+ it("should filter out the defaults that have been blocked", async () => {
+ // make sure we only have one top site, and we block the only default site we have to show
+ const url = "www.myonlytopsite.com";
+ const topsite = {
+ frecency: FAKE_FRECENCY,
+ hostname: shortURLStub({ url }),
+ typedBonus: true,
+ url,
+ };
+ const blockedDefaultSite = { url: "https://foo.com" };
+ fakeNewTabUtils.activityStreamLinks.getTopSites = () => [topsite];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ site.url === blockedDefaultSite.url;
+ const result = await feed.getLinksWithDefaults();
+
+ // what we should be left with is just the top site we added, and not the default site we blocked
+ assert.lengthOf(result, 1);
+ assert.deepEqual(result[0], topsite);
+ assert.notInclude(result, blockedDefaultSite);
+ });
+ it("should call dedupe on the links", async () => {
+ const stub = sinon.stub(feed.dedupe, "group").callsFake((...id) => id);
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledOnce(stub);
+ });
+ it("should dedupe the links by hostname", async () => {
+ const site = { url: "foo", hostname: "bar" };
+ const result = feed._dedupeKey(site);
+
+ assert.equal(result, site.hostname);
+ });
+ it("should add defaults if there are are not enough links", async () => {
+ links = [{ frecency: FAKE_FRECENCY, url: "foo.com" }];
+
+ const result = await feed.getLinksWithDefaults();
+ const reference = [...links, ...DEFAULT_TOP_SITES].map(s =>
+ Object.assign({}, s, {
+ hostname: shortURLStub(s),
+ typedBonus: true,
+ })
+ );
+
+ assert.deepEqual(result, reference);
+ });
+ it("should only add defaults up to the number of visible slots", async () => {
+ links = [];
+ const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
+ for (let i = 0; i < numVisible - 1; i++) {
+ links.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` });
+ }
+ const result = await feed.getLinksWithDefaults();
+ const reference = [...links, DEFAULT_TOP_SITES[0]].map(s =>
+ Object.assign({}, s, {
+ hostname: shortURLStub(s),
+ typedBonus: true,
+ })
+ );
+
+ assert.lengthOf(result, numVisible);
+ assert.deepEqual(result, reference);
+ });
+ it("should not throw if NewTabUtils returns null", () => {
+ links = null;
+ assert.doesNotThrow(() => {
+ feed.getLinksWithDefaults();
+ });
+ });
+ it("should get more if the user has asked for more", async () => {
+ links = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW)
+ .fill(null)
+ .map((v, i) => ({
+ frecency: FAKE_FRECENCY,
+ url: `http://www.site${i}.com`,
+ }));
+ feed.store.state.Prefs.values.topSitesRows = 3;
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(
+ result,
+ "length",
+ feed.store.state.Prefs.values.topSitesRows *
+ TOP_SITES_MAX_SITES_PER_ROW
+ );
+ });
+ });
+ describe("caching", () => {
+ it("should reuse the cache on subsequent calls", async () => {
+ await feed.getLinksWithDefaults();
+ await feed.getLinksWithDefaults();
+
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should ignore the cache when requesting more", async () => {
+ await feed.getLinksWithDefaults();
+ feed.store.state.Prefs.values.topSitesRows *= 3;
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should migrate frecent screenshot data without getting screenshots again", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ stubFaviconsToUseScreenshots();
+ await feed.getLinksWithDefaults();
+ const { callCount } = fakeScreenshot.getScreenshotForURL;
+ feed.frecentCache.expire();
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites);
+ assert.callCount(fakeScreenshot.getScreenshotForURL, callCount);
+ assert.propertyVal(result[0], "screenshot", FAKE_SCREENSHOT);
+ });
+ it("should migrate pinned favicon data without getting favicons again", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+ await feed.getLinksWithDefaults();
+ const { callCount } =
+ fakeNewTabUtils.activityStreamProvider._addFavicons;
+ feed.pinnedCache.expire();
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.callCount(
+ fakeNewTabUtils.activityStreamProvider._addFavicons,
+ callCount
+ );
+ assert.propertyVal(result[0], "favicon", FAKE_FAVICON);
+ assert.propertyVal(result[0], "faviconSize", FAKE_FAVICON_SIZE);
+ });
+ it("should not expose internal link properties", async () => {
+ const result = await feed.getLinksWithDefaults();
+
+ const internal = Object.keys(result[0]).filter(key =>
+ key.startsWith("__")
+ );
+ assert.equal(internal.join(""), "");
+ });
+ it("should copy the screenshot of the frecent site if pinned site doesn't have customScreenshotURL", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "screenshot" }];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, links[0].screenshot);
+ });
+ it("should not copy the frecent screenshot if customScreenshotURL is set", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "screenshot" }];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", customScreenshotURL: "custom" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.isUndefined(result[0].screenshot);
+ });
+ it("should keep the same screenshot if no frecent site is found", async () => {
+ links = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", screenshot: "custom" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, "custom");
+ });
+ it("should not overwrite pinned site screenshot", async () => {
+ links = [{ url: "https://foo.com/", screenshot: "foo" }];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com/", screenshot: "bar" },
+ ];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.equal(result[0].screenshot, "bar");
+ });
+ it("should not set searchTopSite from frecent site", async () => {
+ links = [
+ {
+ url: "https://foo.com/",
+ searchTopSite: true,
+ screenshot: "screenshot",
+ },
+ ];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ const result = await feed.getLinksWithDefaults();
+
+ assert.propertyVal(result[0], "searchTopSite", false);
+ // But it should copy over other properties
+ assert.propertyVal(result[0], "screenshot", "screenshot");
+ });
+ describe("concurrency", () => {
+ beforeEach(() => {
+ stubFaviconsToUseScreenshots();
+ fakeScreenshot.getScreenshotForURL = sandbox
+ .stub()
+ .resolves(FAKE_SCREENSHOT);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const getTwice = () =>
+ Promise.all([
+ feed.getLinksWithDefaults(),
+ feed.getLinksWithDefaults(),
+ ]);
+
+ it("should call the backing data once", async () => {
+ await getTwice();
+
+ assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
+ });
+ it("should get screenshots once per link", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ await getTwice();
+
+ assert.callCount(
+ fakeScreenshot.getScreenshotForURL,
+ FAKE_LINKS.length
+ );
+ });
+ it("should dispatch once per link screenshot fetched", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ feed._requestRichIcon = sinon.stub();
+ await getTwice();
+
+ assert.callCount(feed.store.dispatch, FAKE_LINKS.length);
+ });
+ });
+ });
+ describe("deduping", () => {
+ beforeEach(() => {
+ ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "common/Reducers.jsm": {
+ insertPinned,
+ TOP_SITES_DEFAULT_ROWS,
+ TOP_SITES_MAX_SITES_PER_ROW,
+ },
+ "lib/Screenshots.jsm": { Screenshots: fakeScreenshot },
+ }));
+ sandbox.stub(global.Services.eTLD, "getPublicSuffix").returns("com");
+ feed = Object.assign(new TopSitesFeed(), { store: feed.store });
+ });
+ it("should not dedupe pinned sites", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://developer.mozilla.org/en-US/docs/Web" },
+ { url: "https://developer.mozilla.org/en-US/docs/Learn" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ assert.lengthOf(sites, 2 * TOP_SITES_MAX_SITES_PER_ROW);
+ assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url);
+ assert.equal(sites[0].hostname, sites[1].hostname);
+ });
+ it("should prefer pinned sites over links", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://developer.mozilla.org/en-US/docs/Web" },
+ { url: "https://developer.mozilla.org/en-US/docs/Learn" },
+ ];
+ // These will be the frecent results.
+ links = [
+ { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" },
+ { frecency: FAKE_FRECENCY, url: "https://www.mozilla.org/" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so
+ // the frecent with matching hostname as pinned is removed.
+ assert.lengthOf(sites, 3);
+ assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
+ assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url);
+ assert.equal(sites[2].url, links[1].url);
+ });
+ it("should return sites that have a title", async () => {
+ // Simulate a pinned link with no title.
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://github.com/mozilla/activity-stream" },
+ ];
+
+ const sites = await feed.getLinksWithDefaults();
+
+ for (const site of sites) {
+ assert.isDefined(site.hostname);
+ }
+ });
+ it("should check against null entries", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [null];
+
+ await feed.getLinksWithDefaults();
+ });
+ });
+ it("should call _fetchIcon for each link", async () => {
+ sinon.spy(feed, "_fetchIcon");
+
+ const results = await feed.getLinksWithDefaults();
+
+ assert.callCount(feed._fetchIcon, results.length);
+ results.forEach(link => {
+ assert.calledWith(feed._fetchIcon, link);
+ });
+ });
+ it("should call _fetchScreenshot when customScreenshotURL is set", async () => {
+ links = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com", customScreenshotURL: "custom" },
+ ];
+ sinon.stub(feed, "_fetchScreenshot");
+
+ await feed.getLinksWithDefaults();
+
+ assert.calledWith(feed._fetchScreenshot, sinon.match.object, "custom");
+ });
+ describe("discoverystream", () => {
+ let makeStreamData = index => ({
+ layout: [
+ {
+ components: [
+ {
+ placement: {
+ name: "sponsored-topsites",
+ },
+ spocs: {
+ positions: [{ index }],
+ },
+ },
+ ],
+ },
+ ],
+ spocs: {
+ data: {
+ "sponsored-topsites": {
+ items: [{ title: "test spoc", url: "https://test-spoc.com" }],
+ },
+ },
+ },
+ });
+ it("should add a sponsored topsite from discoverystream to all the valid indices", async () => {
+ for (let i = 0; i < FAKE_LINKS.length; i++) {
+ feed.store.state.DiscoveryStream = makeStreamData(i);
+ const result = await feed.getLinksWithDefaults();
+ const link = result[i];
+
+ assert.equal(link.type, "SPOC");
+ assert.equal(link.title, "test spoc");
+ assert.equal(link.sponsored_position, i + 1);
+ assert.equal(link.hostname, "test-spoc");
+ assert.equal(link.url, "https://test-spoc.com");
+ }
+ });
+ });
+ });
+ describe("#init", () => {
+ it("should call refresh (broadcast:true)", async () => {
+ sandbox.stub(feed, "refresh");
+
+ await feed.init();
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, {
+ broadcast: true,
+ isStartup: true,
+ });
+ });
+ it("should initialise the storage", async () => {
+ await feed.init();
+
+ assert.calledOnce(feed.store.dbStorage.getDbTable);
+ assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs");
+ });
+ it("should call onUpdate to set up Nimbus update listener", async () => {
+ await feed.init();
+
+ assert.calledOnce(fakeNimbusFeatures.newtab.onUpdate);
+ });
+ });
+ describe("#refresh", () => {
+ beforeEach(() => {
+ sandbox.stub(feed, "_fetchIcon");
+ feed._startedUp = true;
+ });
+ it("should wait for tippytop to initialize", async () => {
+ feed._tippyTopProvider.initialized = false;
+ sinon.stub(feed._tippyTopProvider, "init").resolves();
+
+ await feed.refresh();
+
+ assert.calledOnce(feed._tippyTopProvider.init);
+ });
+ it("should not init the tippyTopProvider if already initialized", async () => {
+ feed._tippyTopProvider.initialized = true;
+ sinon.stub(feed._tippyTopProvider, "init").resolves();
+
+ await feed.refresh();
+
+ assert.notCalled(feed._tippyTopProvider.init);
+ });
+ it("should broadcast TOP_SITES_UPDATED", async () => {
+ sinon.stub(feed, "getLinksWithDefaults").returns(Promise.resolve([]));
+
+ await feed.refresh({ broadcast: true });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.TOP_SITES_UPDATED,
+ data: { links: [], pref: { collapsed: false } },
+ })
+ );
+ });
+ it("should dispatch an action with the links returned", async () => {
+ await feed.refresh({ broadcast: true });
+ const reference = links.map(site =>
+ Object.assign({}, site, {
+ hostname: shortURLStub(site),
+ typedBonus: true,
+ })
+ );
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.propertyVal(
+ feed.store.dispatch.firstCall.args[0],
+ "type",
+ at.TOP_SITES_UPDATED
+ );
+ assert.deepEqual(
+ feed.store.dispatch.firstCall.args[0].data.links,
+ reference
+ );
+ });
+ it("should handle empty slots in the resulting top sites array", async () => {
+ links = [FAKE_LINKS[0]];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ FAKE_LINKS[1],
+ null,
+ null,
+ null,
+ null,
+ null,
+ FAKE_LINKS[2],
+ ];
+ await feed.refresh({ broadcast: true });
+ assert.calledOnce(feed.store.dispatch);
+ });
+ it("should dispatch AlsoToPreloaded when broadcast is false", async () => {
+ sandbox.stub(feed, "getLinksWithDefaults").returns([]);
+ await feed.refresh({ broadcast: false });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.AlsoToPreloaded({
+ type: at.TOP_SITES_UPDATED,
+ data: { links: [], pref: { collapsed: false } },
+ })
+ );
+ });
+ it("should not init storage if it is already initialized", async () => {
+ feed._storage.initialized = true;
+
+ await feed.refresh({ broadcast: false });
+
+ assert.notCalled(feed._storage.init);
+ });
+ it("should catch indexedDB errors", async () => {
+ feed._storage.get.throws(new Error());
+ globals.sandbox.spy(global.console, "error");
+
+ try {
+ await feed.refresh({ broadcast: false });
+ } catch (e) {
+ assert.fails();
+ }
+
+ assert.calledOnce(console.error);
+ });
+ });
+ describe("#updateSectionPrefs", () => {
+ it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => {
+ sandbox.stub(feed, "updateSectionPrefs");
+
+ feed.onAction({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: "topsites" },
+ });
+
+ assert.calledOnce(feed.updateSectionPrefs);
+ });
+ it("should dispatch TOP_SITES_PREFS_UPDATED", async () => {
+ await feed.updateSectionPrefs({ collapsed: true });
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.BroadcastToContent({
+ type: at.TOP_SITES_PREFS_UPDATED,
+ data: { pref: { collapsed: true } },
+ })
+ );
+ });
+ });
+ describe("#getScreenshotPreview", () => {
+ it("should dispatch preview if request is succesful", async () => {
+ await feed.getScreenshotPreview("custom", 1234);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.OnlyToOneContent(
+ {
+ data: { preview: FAKE_SCREENSHOT, url: "custom" },
+ type: at.PREVIEW_RESPONSE,
+ },
+ 1234
+ )
+ );
+ });
+ it("should return empty string if request fails", async () => {
+ fakeScreenshot.getScreenshotForURL = sandbox
+ .stub()
+ .returns(Promise.resolve(null));
+ await feed.getScreenshotPreview("custom", 1234);
+
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWithExactly(
+ feed.store.dispatch,
+ ac.OnlyToOneContent(
+ {
+ data: { preview: "", url: "custom" },
+ type: at.PREVIEW_RESPONSE,
+ },
+ 1234
+ )
+ );
+ });
+ });
+ describe("#_fetchIcon", () => {
+ it("should reuse screenshot on the link", () => {
+ const link = { screenshot: "reuse.png" };
+
+ feed._fetchIcon(link);
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ assert.propertyVal(link, "screenshot", "reuse.png");
+ });
+ it("should reuse existing fetching screenshot on the link", async () => {
+ const link = {
+ __sharedCache: { fetchingScreenshot: Promise.resolve("fetching.png") },
+ };
+
+ await feed._fetchIcon(link);
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should get a screenshot if the link is missing it", () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0]));
+
+ assert.calledOnce(fakeScreenshot.getScreenshotForURL);
+ assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_LINKS[0].url);
+ });
+ it("should not get a screenshot if the link is missing it but top sites aren't shown", () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = false;
+ feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0]));
+
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should update the link's cache with a screenshot", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ const updateLink = sandbox.stub();
+ const link = { __sharedCache: { updateLink } };
+
+ await feed._fetchIcon(link);
+
+ assert.calledOnce(updateLink);
+ assert.calledWith(updateLink, "screenshot", FAKE_SCREENSHOT);
+ });
+ it("should skip getting a screenshot if there is a tippy top icon", () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ const link = { url: "example.com" };
+ feed._fetchIcon(link);
+ assert.propertyVal(link, "tippyTopIcon", "icon.png");
+ assert.notProperty(link, "screenshot");
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should skip getting a screenshot if there is an icon of size greater than 96x96 and no tippy top", () => {
+ const link = {
+ url: "foo.com",
+ favicon: "data:foo",
+ faviconSize: 196,
+ };
+ feed._fetchIcon(link);
+ assert.notProperty(link, "tippyTopIcon");
+ assert.notProperty(link, "screenshot");
+ assert.notCalled(fakeScreenshot.getScreenshotForURL);
+ });
+ it("should use the link's rich icon even if there's a tippy top", () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ const link = {
+ url: "foo.com",
+ favicon: "data:foo",
+ faviconSize: 196,
+ };
+ feed._fetchIcon(link);
+ assert.notProperty(link, "tippyTopIcon");
+ });
+ });
+ describe("#_fetchScreenshot", () => {
+ it("should call maybeCacheScreenshot", async () => {
+ feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true;
+ const updateLink = sinon.stub();
+ const link = {
+ customScreenshotURL: "custom",
+ __sharedCache: { updateLink },
+ };
+ await feed._fetchScreenshot(link, "custom");
+
+ assert.calledOnce(fakeScreenshot.maybeCacheScreenshot);
+ assert.calledWithExactly(
+ fakeScreenshot.maybeCacheScreenshot,
+ link,
+ link.customScreenshotURL,
+ "screenshot",
+ sinon.match.func
+ );
+ });
+ it("should not call maybeCacheScreenshot if screenshot is set", async () => {
+ const updateLink = sinon.stub();
+ const link = {
+ customScreenshotURL: "custom",
+ __sharedCache: { updateLink },
+ screenshot: true,
+ };
+ await feed._fetchScreenshot(link, "custom");
+
+ assert.notCalled(fakeScreenshot.maybeCacheScreenshot);
+ });
+ });
+ describe("#onAction", () => {
+ it("should call getScreenshotPreview on PREVIEW_REQUEST", () => {
+ sandbox.stub(feed, "getScreenshotPreview");
+
+ feed.onAction({
+ type: at.PREVIEW_REQUEST,
+ data: { url: "foo" },
+ meta: { fromTarget: 1234 },
+ });
+
+ assert.calledOnce(feed.getScreenshotPreview);
+ assert.calledWithExactly(feed.getScreenshotPreview, "foo", 1234);
+ });
+ it("should refresh on SYSTEM_TICK", async () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: false });
+ });
+ it("should call with correct parameters on TOP_SITES_PIN", () => {
+ const pinAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: { url: "foo.com" }, index: 7 },
+ };
+ feed.onAction(pinAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ pinAction.data.site,
+ pinAction.data.index
+ );
+ });
+ it("should call pin on TOP_SITES_PIN", () => {
+ sinon.stub(feed, "pin");
+ const pinExistingAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: FAKE_LINKS[4], index: 4 },
+ };
+
+ feed.onAction(pinExistingAction);
+
+ assert.calledOnce(feed.pin);
+ });
+ it("should trigger refresh on TOP_SITES_PIN", async () => {
+ sinon.stub(feed, "refresh");
+ const pinExistingAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: FAKE_LINKS[4], index: 4 },
+ };
+
+ await feed.pin(pinExistingAction);
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should unblock a previously blocked top site if we are now adding it manually via 'Add a Top Site' option", async () => {
+ const pinAction = {
+ type: at.TOP_SITES_PIN,
+ data: { site: { url: "foo.com" }, index: -1 },
+ };
+ feed.onAction(pinAction);
+ assert.calledWith(fakeNewTabUtils.blockedLinks.unblock, {
+ url: pinAction.data.site.url,
+ });
+ });
+ it("should call insert on TOP_SITES_INSERT", async () => {
+ sinon.stub(feed, "insert");
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.com" } },
+ };
+
+ feed.onAction(addAction);
+
+ assert.calledOnce(feed.insert);
+ });
+ it("should trigger refresh on TOP_SITES_INSERT", async () => {
+ sinon.stub(feed, "refresh");
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.com" } },
+ };
+
+ await feed.insert(addAction);
+
+ assert.calledOnce(feed.refresh);
+ });
+ it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => {
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ { url: "foo.com" },
+ null,
+ null,
+ null,
+ null,
+ null,
+ FAKE_LINKS[0],
+ ];
+ const unpinAction = {
+ type: at.TOP_SITES_UNPIN,
+ data: { site: { url: "foo.com" } },
+ };
+ feed.onAction(unpinAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.unpin,
+ unpinAction.data.site
+ );
+ });
+ it("should call refresh without a target if we clear history with PLACES_HISTORY_CLEARED", () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.PLACES_HISTORY_CLEARED });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should call refresh without a target if we remove a Topsite from history", () => {
+ sandbox.stub(feed, "refresh");
+
+ feed.onAction({ type: at.PLACES_LINKS_DELETED });
+
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should still dispatch an action even if there's no target provided", async () => {
+ sandbox.stub(feed, "_fetchIcon");
+ feed._startedUp = true;
+ await feed.refresh({ broadcast: true });
+ assert.calledOnce(feed.store.dispatch);
+ assert.propertyVal(
+ feed.store.dispatch.firstCall.args[0],
+ "type",
+ at.TOP_SITES_UPDATED
+ );
+ });
+ it("should call init on INIT action", async () => {
+ sinon.stub(feed, "init");
+ feed.onAction({ type: at.INIT });
+ assert.calledOnce(feed.init);
+ });
+ it("should call refresh on PLACES_LINK_BLOCKED action", async () => {
+ sinon.stub(feed, "refresh");
+ await feed.onAction({ type: at.PLACES_LINK_BLOCKED });
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: true });
+ });
+ it("should call refresh on PLACES_LINKS_CHANGED action", async () => {
+ sinon.stub(feed, "refresh");
+ await feed.onAction({ type: at.PLACES_LINKS_CHANGED });
+ assert.calledOnce(feed.refresh);
+ assert.calledWithExactly(feed.refresh, { broadcast: false });
+ });
+ it("should call pin with correct args on TOP_SITES_INSERT without an index specified", () => {
+ const addAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.bar", label: "foo" } },
+ };
+ feed.onAction(addAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ addAction.data.site,
+ 0
+ );
+ });
+ it("should call pin with correct args on TOP_SITES_INSERT", () => {
+ const dropAction = {
+ type: at.TOP_SITES_INSERT,
+ data: { site: { url: "foo.bar", label: "foo" }, index: 3 },
+ };
+ feed.onAction(dropAction);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ dropAction.data.site,
+ 3
+ );
+ });
+ it("should remove the expiration filter on UNINIT", () => {
+ feed.onAction({ type: "UNINIT" });
+
+ assert.calledOnce(fakePageThumbs.removeExpirationFilter);
+ });
+ it("should call updatePinnedSearchShortcuts on UPDATE_PINNED_SEARCH_SHORTCUTS action", async () => {
+ sinon.stub(feed, "updatePinnedSearchShortcuts");
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ await feed.onAction({
+ type: at.UPDATE_PINNED_SEARCH_SHORTCUTS,
+ data: { addedShortcuts },
+ });
+ assert.calledOnce(feed.updatePinnedSearchShortcuts);
+ });
+ it("should refresh from Contile on SHOW_SPONSORED_PREF if Contile is enabled", () => {
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ feed.onAction(prefChangeAction);
+
+ assert.calledOnce(feed._contile.refresh);
+ });
+ it("should not refresh from Contile on SHOW_SPONSORED_PREF if Contile is disabled", () => {
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ feed.onAction(prefChangeAction);
+
+ assert.notCalled(feed._contile.refresh);
+ });
+ it("should reset Contile cache prefs when SHOW_SPONSORED_PREF is false", () => {
+ Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]");
+ Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 15 * 60 * 1000);
+ Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, Date.now());
+
+ sandbox.spy(feed._contile, "refresh");
+ const prefChangeAction = {
+ type: at.PREF_CHANGED,
+ data: { name: SHOW_SPONSORED_PREF, value: false },
+ };
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ feed.onAction(prefChangeAction);
+
+ assert.calledOnce(feed._contile.refresh);
+
+ // cached pref values should have reset
+ assert.isUndefined(Services.prefs.getStringPref(CONTILE_CACHE_PREF));
+ assert.isUndefined(
+ Services.prefs.getIntPref(CONTILE_CACHE_LAST_FETCH_PREF)
+ );
+ assert.isUndefined(
+ Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF)
+ );
+ });
+ });
+ describe("#add", () => {
+ it("should pin site in first slot of empty pinned list", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should pin site in first slot of pinned list with empty first slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should move a pinned site in first slot to the next slot: part 1", () => {
+ const site1 = { url: "example.com" };
+ fakeNewTabUtils.pinnedLinks.links = [site1];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ });
+ it("should move a pinned site in first slot to the next slot: part 2", () => {
+ const site1 = { url: "example.com" };
+ const site2 = { url: "example.org" };
+ fakeNewTabUtils.pinnedLinks.links = [site1, null, site2];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ });
+ it("should unpin the last site if all slots are already pinned", () => {
+ const site1 = { url: "example.com" };
+ const site2 = { url: "example.org" };
+ const site3 = { url: "example.net" };
+ const site4 = { url: "example.biz" };
+ const site5 = { url: "example.info" };
+ const site6 = { url: "example.news" };
+ const site7 = { url: "example.lol" };
+ const site8 = { url: "example.golf" };
+ fakeNewTabUtils.pinnedLinks.links = [
+ site1,
+ site2,
+ site3,
+ site4,
+ site5,
+ site6,
+ site7,
+ site8,
+ ];
+ feed.store.state.Prefs.values.topSitesRows = 1;
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { site } });
+ assert.equal(fakeNewTabUtils.pinnedLinks.pin.callCount, 8);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site3, 3);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site4, 4);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site5, 5);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site6, 6);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site7, 7);
+ });
+ });
+ describe("#pin", () => {
+ it("should pin site in specified slot empty pinned list", async () => {
+ const site = {
+ url: "foo.bar",
+ label: "foo",
+ customScreenshotURL: "screenshot",
+ };
+ await feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should lookup the link object to update the custom screenshot", async () => {
+ const site = {
+ url: "foo.bar",
+ label: "foo",
+ customScreenshotURL: "screenshot",
+ };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.calledOnce(feed.pinnedCache.request);
+ });
+ it("should lookup the link object to update the custom screenshot", async () => {
+ const site = { url: "foo.bar", label: "foo", customScreenshotURL: null };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.calledOnce(feed.pinnedCache.request);
+ });
+ it("should not do a link object lookup if custom screenshot field is not set", async () => {
+ const site = { url: "foo.bar", label: "foo" };
+ sandbox.spy(feed.pinnedCache, "request");
+
+ await feed.pin({ data: { index: 2, site } });
+
+ assert.notCalled(feed.pinnedCache.request);
+ });
+ it("should pin site in specified slot of pinned list that is free", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should save the searchTopSite attribute if set", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo", searchTopSite: true };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.propertyVal(
+ fakeNewTabUtils.pinnedLinks.pin.firstCall.args[0],
+ "searchTopSite",
+ true
+ );
+ });
+ it("should NOT move a pinned site in specified slot to the next slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.pin({ data: { index: 2, site } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should properly update LinksCache object properties between migrations", async () => {
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }];
+
+ let pinnedLinks = await feed.pinnedCache.request();
+ assert.equal(pinnedLinks.length, 1);
+ feed.pinnedCache.expire();
+ pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo");
+
+ pinnedLinks = await feed.pinnedCache.request();
+ assert.propertyVal(pinnedLinks[0], "screenshot", "foo");
+
+ // Force cache expiration in order to trigger a migration of objects
+ feed.pinnedCache.expire();
+ pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar");
+
+ pinnedLinks = await feed.pinnedCache.request();
+ assert.propertyVal(pinnedLinks[0], "screenshot", "bar");
+ });
+ it("should call insert if index < 0", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index: -1, site } };
+
+ sandbox.spy(feed, "insert");
+ feed.pin(action);
+
+ assert.calledOnce(feed.insert);
+ assert.calledWithExactly(feed.insert, action);
+ });
+ it("should not call insert if index == 0", () => {
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index: 0, site } };
+
+ sandbox.spy(feed, "insert");
+ feed.pin(action);
+
+ assert.notCalled(feed.insert);
+ });
+ });
+ describe("clearLinkCustomScreenshot", () => {
+ it("should remove cached screenshot if custom url changes", async () => {
+ const stub = sandbox.stub();
+ sandbox.stub(feed.pinnedCache, "request").returns(
+ Promise.resolve([
+ {
+ url: "foo",
+ customScreenshotURL: "old_screenshot",
+ __sharedCache: { updateLink: stub },
+ },
+ ])
+ );
+
+ await feed._clearLinkCustomScreenshot({
+ url: "foo",
+ customScreenshotURL: "new_screenshot",
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, "screenshot", undefined);
+ });
+ it("should remove cached screenshot if custom url is removed", async () => {
+ const stub = sandbox.stub();
+ sandbox.stub(feed.pinnedCache, "request").returns(
+ Promise.resolve([
+ {
+ url: "foo",
+ customScreenshotURL: "old_screenshot",
+ __sharedCache: { updateLink: stub },
+ },
+ ])
+ );
+
+ await feed._clearLinkCustomScreenshot({
+ url: "foo",
+ customScreenshotURL: "new_screenshot",
+ });
+
+ assert.calledOnce(stub);
+ assert.calledWithExactly(stub, "screenshot", undefined);
+ });
+ });
+ describe("#drop", () => {
+ it("should correctly handle different index values", () => {
+ let index = -1;
+ const site = { url: "foo.bar", label: "foo" };
+ const action = { data: { index, site } };
+
+ feed.insert(action);
+
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+
+ index = undefined;
+ feed.insert(action);
+
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0);
+ });
+ it("should pin site in specified slot that is free", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 2, site, draggedFromIndex: 0 } });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ });
+ it("should move a pinned site in specified slot to the next slot", () => {
+ fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }];
+ const site = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 2, site, draggedFromIndex: 3 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ { url: "example.com" },
+ 3
+ );
+ });
+ it("should move pinned sites in the direction of the dragged site", () => {
+ const site1 = { url: "foo.bar", label: "foo" };
+ const site2 = { url: "example.com", label: "example" };
+ fakeNewTabUtils.pinnedLinks.links = [null, null, site2];
+ feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 0 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 1);
+ fakeNewTabUtils.pinnedLinks.pin.resetHistory();
+ feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 5 } });
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 3);
+ });
+ it("should not insert past the visible top sites", () => {
+ const site1 = { url: "foo.bar", label: "foo" };
+ feed.insert({ data: { index: 42, site: site1, draggedFromIndex: 0 } });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ });
+ });
+ describe("integration", () => {
+ let resolvers = [];
+ beforeEach(() => {
+ feed.store.dispatch = sandbox.stub().callsFake(() => {
+ resolvers.shift()();
+ });
+ feed._startedUp = true;
+ sandbox.stub(feed, "_fetchScreenshot");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ const forDispatch = action =>
+ new Promise(resolve => {
+ resolvers.push(resolve);
+ feed.onAction(action);
+ });
+
+ it("should add a pinned site and remove it", async () => {
+ feed._requestRichIcon = sinon.stub();
+ const url = "https://pin.me";
+ fakeNewTabUtils.pinnedLinks.pin = sandbox.stub().callsFake(link => {
+ fakeNewTabUtils.pinnedLinks.links.push(link);
+ });
+
+ await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } });
+ fakeNewTabUtils.pinnedLinks.links.pop();
+ await forDispatch({ type: at.PLACES_LINK_BLOCKED });
+
+ assert.calledTwice(feed.store.dispatch);
+ assert.equal(
+ feed.store.dispatch.firstCall.args[0].data.links[0].url,
+ url
+ );
+ assert.equal(
+ feed.store.dispatch.secondCall.args[0].data.links[0].url,
+ FAKE_LINKS[0].url
+ );
+ });
+ });
+
+ describe("improvesearch.noDefaultSearchTile experiment", () => {
+ const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile";
+ beforeEach(() => {
+ global.Services.search.getDefault = async () => ({
+ identifier: "google",
+ searchForm: "google.com",
+ });
+ feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true;
+ });
+ it("should filter out alexa top 5 search from the default sites", async () => {
+ const TOP_5_TEST = [
+ "google.com",
+ "search.yahoo.com",
+ "yahoo.com",
+ "bing.com",
+ "ask.com",
+ "duckduckgo.com",
+ ];
+ links = [{ url: "amazon.com" }, ...TOP_5_TEST.map(url => ({ url }))];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "amazon.com");
+ TOP_5_TEST.forEach(url => assert.notInclude(urlsReturned, url));
+ });
+ it("should not filter out alexa, default search from the query results if the experiment pref is off", async () => {
+ links = [
+ { url: "google.com" },
+ { url: "foo.com" },
+ { url: "duckduckgo" },
+ ];
+ feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false;
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "google.com");
+ });
+ it("should filter out the current default search from the default sites", async () => {
+ feed._currentSearchHostname = "amazon";
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "google.com,amazon.com" },
+ });
+ links = [{ url: "foo.com" }];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.notInclude(urlsReturned, "amazon.com");
+ });
+ it("should not filter out current default search from pinned sites even if it matches the current default search", async () => {
+ links = [{ url: "foo.com" }];
+ fakeNewTabUtils.pinnedLinks.links = [{ url: "google.com" }];
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.include(urlsReturned, "google.com");
+ });
+ it("should call refresh and set ._currentSearchHostname to the new engine hostname when the the default search engine has been set", () => {
+ sinon.stub(feed, "refresh");
+ sandbox
+ .stub(global.Services.search, "defaultEngine")
+ .value({ identifier: "ddg", searchForm: "duckduckgo.com" });
+ feed.observe(null, "browser-search-engine-modified", "engine-default");
+ assert.equal(feed._currentSearchHostname, "duckduckgo");
+ assert.calledOnce(feed.refresh);
+ });
+ it("should call refresh when the experiment pref has changed", () => {
+ sinon.stub(feed, "refresh");
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true },
+ });
+ assert.calledOnce(feed.refresh);
+
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false },
+ });
+ assert.calledTwice(feed.refresh);
+ });
+ });
+
+ describe("improvesearch.topSitesSearchShortcuts", () => {
+ beforeEach(() => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true;
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] =
+ "google,amazon";
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "";
+ const searchEngines = [
+ { aliases: ["@google"] },
+ { aliases: ["@amazon"] },
+ ];
+ global.Services.search.getAppProvidedEngines = async () => searchEngines;
+ fakeNewTabUtils.pinnedLinks.pin = sinon
+ .stub()
+ .callsFake((site, index) => {
+ fakeNewTabUtils.pinnedLinks.links[index] = site;
+ });
+ });
+
+ it("should properly disable search improvements if the pref is off", async () => {
+ sandbox.stub(global.Services.prefs, "clearUserPref");
+ sandbox.spy(feed.pinnedCache, "expire");
+ sandbox.spy(feed, "refresh");
+
+ // an actual implementation of unpin (until we can get a mochitest for search improvements)
+ fakeNewTabUtils.pinnedLinks.unpin = sinon.stub().callsFake(site => {
+ let index = -1;
+ for (let i = 0; i < fakeNewTabUtils.pinnedLinks.links.length; i++) {
+ let link = fakeNewTabUtils.pinnedLinks.links[i];
+ if (link && link.url === site.url) {
+ index = i;
+ }
+ }
+ if (index > -1) {
+ fakeNewTabUtils.pinnedLinks.links[index] = null;
+ }
+ });
+
+ // ensure we've inserted search shorcuts + pin an additional site in space 4
+ await feed._maybeInsertSearchShortcuts(fakeNewTabUtils.pinnedLinks.links);
+ fakeNewTabUtils.pinnedLinks.pin({ url: "https://dontunpinme.com" }, 3);
+
+ // turn the experiment off
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: false },
+ });
+
+ // check we cleared the pref, expired the pinned cache, and refreshed the feed
+ assert.calledWith(
+ global.Services.prefs.clearUserPref,
+ `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}`
+ );
+ assert.calledOnce(feed.pinnedCache.expire);
+ assert.calledWith(feed.refresh, { broadcast: true });
+
+ // check that the search shortcuts were removed from the list of pinned sites
+ const urlsReturned = fakeNewTabUtils.pinnedLinks.links
+ .filter(s => s)
+ .map(link => link.url);
+ assert.notInclude(urlsReturned, "https://amazon.com");
+ assert.notInclude(urlsReturned, "https://google.com");
+ assert.include(urlsReturned, "https://dontunpinme.com");
+
+ // check that the positions where the search shortcuts were null, and the additional pinned site is untouched in space 4
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[0], null);
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[1], null);
+ assert.equal(fakeNewTabUtils.pinnedLinks.links[2], undefined);
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://dontunpinme.com",
+ });
+ });
+
+ it("should updateCustomSearchShortcuts when experiment pref is turned on", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ feed.updateCustomSearchShortcuts = sinon.spy();
+
+ // turn the experiment on
+ feed.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true },
+ });
+
+ assert.calledOnce(feed.updateCustomSearchShortcuts);
+ });
+
+ it("should filter out default top sites that match a hostname of a search shortcut if previously blocked", async () => {
+ feed.refreshDefaults("https://amazon.ca");
+ fakeNewTabUtils.blockedLinks.links = [{ url: "https://amazon.com" }];
+ fakeNewTabUtils.blockedLinks.isBlocked = site =>
+ fakeNewTabUtils.blockedLinks.links[0].url === site.url;
+ const urlsReturned = (await feed.getLinksWithDefaults()).map(
+ link => link.url
+ );
+ assert.notInclude(urlsReturned, "https://amazon.ca");
+ });
+
+ it("should update frecent search topsite icon", async () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ links = [{ url: "google.com" }];
+
+ const urlsReturned = await feed.getLinksWithDefaults();
+
+ const defaultSearchTopsite = urlsReturned.find(
+ s => s.url === "google.com"
+ );
+ assert.propertyVal(defaultSearchTopsite, "searchTopSite", true);
+ assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
+ assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
+ });
+ it("should update default search topsite icon", async () => {
+ feed._tippyTopProvider.processSite = site => {
+ site.tippyTopIcon = "icon.png";
+ site.backgroundColor = "#fff";
+ return site;
+ };
+ links = [{ url: "foo.com" }];
+ feed.onAction({
+ type: at.PREFS_INITIAL_VALUES,
+ data: { "default.sites": "google.com,amazon.com" },
+ });
+
+ const urlsReturned = await feed.getLinksWithDefaults();
+
+ const defaultSearchTopsite = urlsReturned.find(
+ s => s.url === "amazon.com"
+ );
+ assert.propertyVal(defaultSearchTopsite, "searchTopSite", true);
+ assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png");
+ assert.equal(defaultSearchTopsite.backgroundColor, "#fff");
+ });
+ it("should dispatch UPDATE_SEARCH_SHORTCUTS on updateCustomSearchShortcuts", async () => {
+ feed.store.state.Prefs.values["improvesearch.noDefaultSearchTile"] = true;
+ await feed.updateCustomSearchShortcuts();
+ assert.calledOnce(feed.store.dispatch);
+ assert.calledWith(feed.store.dispatch, {
+ data: {
+ searchShortcuts: [
+ {
+ keyword: "@google",
+ shortURL: "google",
+ url: "https://google.com",
+ },
+ {
+ keyword: "@amazon",
+ shortURL: "amazon",
+ url: "https://amazon.com",
+ },
+ ],
+ },
+ meta: {
+ from: "ActivityStream:Main",
+ to: "ActivityStream:Content",
+ isStartup: false,
+ },
+ type: "UPDATE_SEARCH_SHORTCUTS",
+ });
+ });
+
+ describe("_maybeInsertSearchShortcuts", () => {
+ beforeEach(() => {
+ // Default is one row
+ feed.store.state.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS;
+ // Eight slots per row
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "" },
+ { url: "" },
+ { url: "" },
+ null,
+ { url: "" },
+ { url: "" },
+ null,
+ { url: "" },
+ ];
+ });
+
+ it("should be called on getLinksWithDefaults", async () => {
+ sandbox.spy(feed, "_maybeInsertSearchShortcuts");
+ await feed.getLinksWithDefaults();
+ assert.calledOnce(feed._maybeInsertSearchShortcuts);
+ });
+
+ it("should do nothing and return false if the experiment is disabled", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false;
+ assert.isFalse(
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ )
+ );
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ });
+
+ it("should pin shortcuts in the correct order, into the available unpinned slots", async () => {
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ // The shouldPin pref is "google,amazon" so expect the shortcuts in that order
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://google.com",
+ searchTopSite: true,
+ label: "@google",
+ });
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[6], {
+ url: "https://amazon.com",
+ searchTopSite: true,
+ label: "@amazon",
+ });
+ });
+
+ it("should not pin shortcuts for the current default search engine", async () => {
+ feed._currentSearchHostname = "google";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], {
+ url: "https://amazon.com",
+ searchTopSite: true,
+ label: "@amazon",
+ });
+ });
+
+ it("should only pin the first shortcut if there's only one available slot", async () => {
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ // The first item in the shouldPin pref is "google" so expect only Google to be pinned
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should pin none if there's no available slot", async () => {
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ fakeNewTabUtils.pinnedLinks.links[6] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should not pin a shortcut if the corresponding search engine is not available", async () => {
+ // Make Amazon search engine unavailable
+ global.Services.search.getAppProvidedEngines = async () => [
+ { aliases: ["@google"] },
+ ];
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should not pin a search shortcut if it's been pinned before", async () => {
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google,amazon";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "amazon";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+
+ fakeNewTabUtils.pinnedLinks.links.fill(null);
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] =
+ "google";
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.notOk(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://google.com"
+ )
+ );
+ assert.ok(
+ fakeNewTabUtils.pinnedLinks.links.find(
+ s => s && s.url === "https://amazon.com"
+ )
+ );
+ });
+
+ it("should record the insertion of a search shortcut", async () => {
+ feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = "";
+ // Fill up one slot, so there's only one left - to be filled by Google
+ fakeNewTabUtils.pinnedLinks.links[3] = { url: "" };
+ await feed._maybeInsertSearchShortcuts(
+ fakeNewTabUtils.pinnedLinks.links
+ );
+ assert.calledWithExactly(feed.store.dispatch, {
+ data: { name: SEARCH_SHORTCUTS_HAVE_PINNED_PREF, value: "google" },
+ meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" },
+ type: "SET_PREF",
+ });
+ });
+ });
+ });
+
+ describe("updatePinnedSearchShortcuts", () => {
+ it("should unpin a shortcut in deletedShortcuts", () => {
+ const deletedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const addedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(fakeNewTabUtils.pinnedLinks.unpin, {
+ url: "https://google.com",
+ });
+ });
+
+ it("should pin a shortcut in addedShortcuts", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = [
+ null,
+ null,
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ {
+ label: "google",
+ searchTopSite: true,
+ searchVendor: "google",
+ url: "https://google.com",
+ },
+ 0
+ );
+ });
+
+ it("should pin and unpin in the same action", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ {
+ url: "https://ebay.com",
+ searchVendor: "ebay",
+ label: "ebay",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ fakeNewTabUtils.pinnedLinks.links = [
+ { url: "https://foo.com" },
+ {
+ url: "https://amazon.com",
+ searchVendor: "amazon",
+ label: "amazon",
+ searchTopSite: true,
+ },
+ ];
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin);
+ });
+
+ it("should pin a shortcut in addedShortcuts even if pinnedLinks is full", () => {
+ const addedShortcuts = [
+ {
+ url: "https://google.com",
+ searchVendor: "google",
+ label: "google",
+ searchTopSite: true,
+ },
+ ];
+ const deletedShortcuts = [];
+ fakeNewTabUtils.pinnedLinks.links = FAKE_LINKS;
+ feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts });
+ assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin);
+ assert.calledWith(
+ fakeNewTabUtils.pinnedLinks.pin,
+ { label: "google", searchTopSite: true, url: "https://google.com" },
+ 0
+ );
+ });
+ });
+
+ describe("#_attachTippyTopIconForSearchShortcut", () => {
+ beforeEach(() => {
+ feed._tippyTopProvider.processSite = site => {
+ if (site.url === "https://www.yandex.ru/") {
+ site.tippyTopIcon = "yandex-ru.png";
+ site.smallFavicon = "yandex-ru.ico";
+ } else if (
+ site.url === "https://www.yandex.com/" ||
+ site.url === "https://yandex.com"
+ ) {
+ site.tippyTopIcon = "yandex.png";
+ site.smallFavicon = "yandex.ico";
+ } else {
+ site.tippyTopIcon = "google.png";
+ site.smallFavicon = "google.ico";
+ }
+ return site;
+ };
+ });
+
+ it("should choose the -ru icons for Yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.yandex.ru/" },
+ });
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex-ru.png");
+ assert.equal(link.smallFavicon, "yandex-ru.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should choose -com icons for Yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.yandex.com/" },
+ });
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex.png");
+ assert.equal(link.smallFavicon, "yandex.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should use the -com icons if can't fetch the search form URL", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves(null);
+
+ const link = { url: "https://yandex.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@yandex");
+
+ assert.equal(link.tippyTopIcon, "yandex.png");
+ assert.equal(link.smallFavicon, "yandex.ico");
+ assert.equal(link.url, "https://yandex.com");
+ });
+
+ it("should choose the correct icon for other non-yandex search shortcut", async () => {
+ sandbox.stub(global.Services.search, "getEngineByAlias").resolves({
+ wrappedJSObject: { _searchForm: "https://www.google.com/" },
+ });
+
+ const link = { url: "https://google.com" };
+ await feed._attachTippyTopIconForSearchShortcut(link, "@google");
+
+ assert.equal(link.tippyTopIcon, "google.png");
+ assert.equal(link.smallFavicon, "google.ico");
+ assert.equal(link.url, "https://google.com");
+ });
+ });
+
+ describe("#ContileIntegration", () => {
+ let getStringPrefStub;
+ let getIntPrefStub;
+ beforeEach(() => {
+ // Turn on sponsored TopSites for testing
+ feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true;
+ fetchStub = sandbox.stub();
+ globals.set("fetch", fetchStub);
+
+ getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref");
+ getStringPrefStub
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+
+ getIntPrefStub = sandbox.stub(global.Services.prefs, "getIntPref");
+
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ sandbox.spy(global.Services.prefs, "setStringPref");
+ sandbox.spy(global.Services.prefs, "setIntPref");
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should fetch sites from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ });
+
+ it("should fetch SOV (Share-of-Voice) settings from Contile", async () => {
+ const sov = {
+ name: "SOV-20230518215316",
+ allocations: [
+ {
+ position: 1,
+ allocation: [
+ {
+ partner: "foo",
+ percentage: 100,
+ },
+ {
+ partner: "bar",
+ percentage: 0,
+ },
+ ],
+ },
+ {
+ position: 2,
+ allocation: [
+ {
+ partner: "foo",
+ percentage: 80,
+ },
+ {
+ partner: "bar",
+ percentage: 20,
+ },
+ ],
+ },
+ ],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ sov: btoa(JSON.stringify(sov)),
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.deepEqual(feed._contile.sov, sov);
+ assert.equal(feed._contile.sites.length, 2);
+ });
+
+ it("should not fetch from Contile if it's not enabled", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ const fetched = await feed._contile._fetchSites();
+
+ assert.notCalled(fetchStub);
+ assert.ok(!fetched);
+ assert.equal(feed._contile.sites.length, 0);
+ });
+
+ it("should still return two tiles when Contile provides more than 2 tiles and filtering results in more than 2 tiles", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(true);
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://bar.com",
+ image_url: "images/bar-com.png",
+ click_url: "https://www.bar-click.com",
+ impression_url: "https://www.bar-impression.com",
+ name: "bar",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ {
+ url: "https://test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ // Both "foo" and "bar" should be filtered
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ });
+
+ it("should still return two tiles with replacement if the Nimbus variable was unset", async () => {
+ fakeNimbusFeatures.newtab.getVariable.reset();
+ fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(undefined);
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ });
+
+ it("should filter the blocked sponsors", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://bar.com",
+ image_url: "images/bar-com.png",
+ click_url: "https://www.bar-click.com",
+ impression_url: "https://www.bar-impression.com",
+ name: "bar",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ // Both "foo" and "bar" should be filtered
+ assert.equal(feed._contile.sites.length, 1);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ });
+
+ it("should return false when Contile returns with error status and no values are stored in cache prefs", async () => {
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should return false when Contile returns with error status and cached tiles are expried", async () => {
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now() - 1000 * 60 * 30);
+
+ fetchStub.resolves({
+ ok: false,
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle invalid payload properly from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () =>
+ Promise.resolve({
+ unknown: [],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle empty payload properly from Contile", async () => {
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should handle no content properly from Contile", async () => {
+ fetchStub.resolves({ ok: true, status: 204 });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(!fetched);
+ assert.ok(!feed._contile.sites.length);
+ });
+
+ it("should set Caching Prefs after a sucessful request", async () => {
+ const tiles = [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles,
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.calledOnce(Services.prefs.setStringPref);
+ assert.calledTwice(Services.prefs.setIntPref);
+
+ assert.calledWith(
+ Services.prefs.setStringPref,
+ CONTILE_CACHE_PREF,
+ JSON.stringify(tiles)
+ );
+ assert.calledWith(
+ Services.prefs.setIntPref,
+ CONTILE_CACHE_VALID_FOR_PREF,
+ 11322
+ );
+ });
+
+ it("should return cached valid tiles when Contile returns error status", async () => {
+ const tiles = [
+ {
+ url: "https://www.test-cached.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://www.test1-cached.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+
+ getStringPrefStub
+ .withArgs(CONTILE_CACHE_PREF)
+ .returns(JSON.stringify(tiles));
+
+ // valid for 15 mins
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now());
+
+ fetchStub.resolves({
+ status: 304,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 2);
+ assert.equal(feed._contile.sites[0].url, "https://www.test-cached.com");
+ assert.equal(feed._contile.sites[1].url, "https://www.test1-cached.com");
+ });
+
+ it("should not be successful when contile returns an error and no valid tiles are cached", async () => {
+ getStringPrefStub.withArgs(CONTILE_CACHE_PREF).returns("[]");
+
+ getIntPrefStub.withArgs(CONTILE_CACHE_VALID_FOR_PREF).returns(0);
+ getIntPrefStub.withArgs(CONTILE_CACHE_LAST_FETCH_PREF).returns(0);
+
+ fetchStub.resolves({
+ status: 500,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(!fetched);
+ });
+
+ it("should return cached valid tiles filtering blocked tiles when Contile returns error status", async () => {
+ const tiles = [
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ },
+ {
+ url: "https://www.test1-cached.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ];
+ getStringPrefStub
+ .withArgs(CONTILE_CACHE_PREF)
+ .returns(JSON.stringify(tiles));
+
+ // valid for 15 mins
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_VALID_FOR_PREF)
+ .returns(1000 * 60 * 15);
+ getIntPrefStub
+ .withArgs(CONTILE_CACHE_LAST_FETCH_PREF)
+ .returns(Date.now());
+
+ fetchStub.resolves({
+ status: 304,
+ });
+
+ const fetched = await feed._contile._fetchSites();
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 1);
+ assert.equal(feed._contile.sites[0].url, "https://www.test1-cached.com");
+ });
+
+ it("should still return 3 tiles when nimbus variable overrides max num of sponsored contile tiles", async () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(3);
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ headers: new Map([
+ ["cache-control", "private, max-age=859, stale-if-error=10463"],
+ ]),
+ json: () =>
+ Promise.resolve({
+ tiles: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ {
+ url: "https://test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ },
+ ],
+ }),
+ });
+
+ const fetched = await feed._contile._fetchSites();
+
+ assert.ok(fetched);
+ assert.equal(feed._contile.sites.length, 3);
+ assert.equal(feed._contile.sites[0].url, "https://www.test.com");
+ assert.equal(feed._contile.sites[1].url, "https://test1.com");
+ assert.equal(feed._contile.sites[2].url, "https://test2.com");
+ });
+ });
+
+ describe("#_mergeSponsoredLinks", () => {
+ let fakeSponsoredLinks;
+ let sov;
+ beforeEach(() => {
+ fakeSponsoredLinks = {
+ amp: [
+ {
+ url: "https://www.test.com",
+ image_url: "images/test-com.png",
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ partner: "amp",
+ sponsored_position: 1,
+ },
+ {
+ url: "https://www.test1.com",
+ image_url: "images/test1-com.png",
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ partner: "amp",
+ sponsored_position: 2,
+ },
+ {
+ url: "https://www.test2.com",
+ image_url: "images/test2-com.png",
+ click_url: "https://www.test2-click.com",
+ impression_url: "https://www.test2-impression.com",
+ name: "test2",
+ partner: "amp",
+ sponsored_position: 2,
+ },
+ ],
+ "moz-sales": [
+ {
+ url: "https://foo.com",
+ image_url: "images/foo-com.png",
+ click_url: "https://www.foo-click.com",
+ impression_url: "https://www.foo-impression.com",
+ name: "foo",
+ partner: "moz-sales",
+ pos: 2,
+ },
+ ],
+ };
+
+ sov = {
+ name: "SOV-20230518215316",
+ allocations: [
+ {
+ position: 1,
+ allocation: [
+ {
+ partner: "amp",
+ percentage: 100,
+ },
+ {
+ partner: "moz-sales",
+ percentage: 0,
+ },
+ ],
+ },
+ {
+ position: 2,
+ allocation: [
+ {
+ partner: "amp",
+ percentage: 80,
+ },
+ {
+ partner: "moz-sales",
+ percentage: 20,
+ },
+ ],
+ },
+ ],
+ };
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should join sponsored links if the sov object is absent", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => null);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
+ });
+
+ it("should join sponosred links if the SOV Nimbus variable is disabled", async () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(false);
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat());
+ });
+
+ it("should pick sponsored links based on sov configurations", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.reset();
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(undefined);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(1);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 2);
+ assert.equal(sponsored[0].partner, "amp");
+ assert.equal(sponsored[0].sponsored_position, 1);
+ assert.equal(sponsored[1].partner, "moz-sales");
+ assert.equal(sponsored[1].sponsored_position, 2);
+ assert.equal(sponsored[1].pos, 1);
+ });
+
+ it("should add remaining contile tiles when nimbus var contile max num sponsored is present", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.reset();
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true);
+ fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(3);
+ global.Sampling.ratioSample.resolves(0);
+
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 3);
+ });
+
+ it("should fall back to other partners if the chosen partner does not have any links", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(0);
+
+ fakeSponsoredLinks.amp = [];
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 1);
+ assert.equal(sponsored[0].partner, "moz-sales");
+ assert.equal(sponsored[0].sponsored_position, 1);
+ assert.equal(sponsored[0].pos, 0);
+ });
+
+ it("should return an empty array if none of the partners have links", async () => {
+ sandbox.stub(feed._contile, "sov").get(() => sov);
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(true);
+ global.Sampling.ratioSample.onCall(0).resolves(0);
+ global.Sampling.ratioSample.onCall(1).resolves(0);
+
+ fakeSponsoredLinks.amp = [];
+ fakeSponsoredLinks["moz-sales"] = [];
+ const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks);
+
+ assert.equal(sponsored.length, 0);
+ });
+ });
+
+ describe("#_readDefaults", () => {
+ beforeEach(() => {
+ // Turn on sponsored TopSites for testing
+ feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true;
+ fetchStub = sandbox.stub();
+ globals.set("fetch", fetchStub);
+ fetchStub.resolves({ ok: true, status: 204 });
+ sandbox
+ .stub(global.Services.prefs, "getBoolPref")
+ .withArgs(REMOTE_SETTING_DEFAULTS_PREF)
+ .returns(true);
+
+ sandbox
+ .stub(global.Services.prefs, "getStringPref")
+ .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF)
+ .returns(`["foo","bar"]`);
+ sandbox.stub(global.Services.prefs, "prefIsLocked").returns(false);
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should filter all blocked sponsored tiles from RemoteSettings when Contile is disabled", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://test.com", title: "test", sponsored_position: 3 },
+ ]);
+ fakeNimbusFeatures.newtab.getVariable.returns(false);
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "test");
+ });
+
+ it("should also filter all blocked sponsored tiles from RemoteSettings when Contile is enabled", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://test.com", title: "test", sponsored_position: 3 },
+ ]);
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "test");
+ });
+
+ it("should not filter non-sponsored tiles from RemoteSettings", async () => {
+ sandbox.stub(feed, "_getRemoteConfig").resolves([
+ { url: "https://foo.com", title: "foo", sponsored_position: 1 },
+ { url: "https://bar.com", title: "bar", sponsored_position: 2 },
+ { url: "https://foo.com", title: "foo" },
+ ]);
+
+ await feed._readDefaults();
+
+ assert.equal(DEFAULT_TOP_SITES.length, 1);
+ assert.equal(DEFAULT_TOP_SITES[0].label, "foo");
+ });
+
+ it("should take the image from Contile if it's a hi-res one", async () => {
+ fakeNimbusFeatures.newtab.getVariable.returns(true);
+ sandbox.stub(feed, "_getRemoteConfig").resolves([]);
+
+ sandbox.stub(feed._contile, "sites").get(() => [
+ {
+ url: "https://test.com",
+ image_url: "https://images.test.com/test-com.png",
+ image_size: 192,
+ click_url: "https://www.test-click.com",
+ impression_url: "https://www.test-impression.com",
+ name: "test",
+ },
+ {
+ url: "https://test1.com",
+ image_url: "https://images.test1.com/test1-com.png",
+ image_size: 32,
+ click_url: "https://www.test1-click.com",
+ impression_url: "https://www.test1-impression.com",
+ name: "test1",
+ },
+ ]);
+
+ await feed._readDefaults();
+
+ const [site1, site2] = DEFAULT_TOP_SITES;
+ assert.propertyVal(
+ site1,
+ "favicon",
+ "https://images.test.com/test-com.png"
+ );
+ assert.propertyVal(site1, "faviconSize", 192);
+
+ // Should not be taken as it's not hi-res
+ assert.isUndefined(site2.favicon);
+ assert.isUndefined(site2.faviconSize);
+ });
+ });
+
+ describe("#_nimbusChangeListener", () => {
+ it("should refresh on Nimbus feature updates reasons", () => {
+ sandbox.spy(feed._contile, "refresh");
+ feed._nimbusChangeListener(null, "experiment-updated");
+
+ assert.calledOnce(feed._contile.refresh);
+ });
+
+ it("should not refresh on Nimbus feature loaded reasons", () => {
+ sandbox.spy(feed._contile, "refresh");
+ feed._nimbusChangeListener(null, "feature-experiment-loaded");
+ feed._nimbusChangeListener(null, "feature-rollout-loaded");
+
+ assert.notCalled(feed._contile.refresh);
+ });
+ });
+
+ describe("#_maybeCapSponsoredLinks", () => {
+ let sponsoredLinks;
+
+ beforeEach(() => {
+ sponsoredLinks = [
+ {
+ url: "https://www.test.com",
+ name: "test",
+ sponsored_position: 1,
+ },
+ {
+ url: "https://www.test1.com",
+ name: "test1",
+ sponsored_position: 2,
+ },
+ {
+ url: "https://www.test2.com",
+ name: "test2",
+ sponsored_position: 3,
+ },
+ ];
+ });
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ it("should fall back to the default if the Nimbus variable is unspecified", () => {
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 2);
+ });
+ it("should cap the links if specified by the Nimbus variable", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(1);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 1);
+ });
+ it("should leave all the links if the Nimbus variable is equal to what we have", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(3);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 3);
+ });
+ it("should ignore caps if they are more than what we have", () => {
+ fakeNimbusFeatures.pocketNewtab.getVariable.returns(10);
+
+ feed._maybeCapSponsoredLinks(sponsoredLinks);
+
+ assert.equal(sponsoredLinks.length, 3);
+ });
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js
new file mode 100644
index 0000000000..f6560d7ab2
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js
@@ -0,0 +1,1903 @@
+import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils";
+import { actionTypes as at } from "common/Actions.sys.mjs";
+import injector from "inject!lib/TopStoriesFeed.jsm";
+
+describe("Top Stories Feed", () => {
+ let TopStoriesFeed;
+ let STORIES_UPDATE_TIME;
+ let TOPICS_UPDATE_TIME;
+ let SECTION_ID;
+ let SPOC_IMPRESSION_TRACKING_PREF;
+ let REC_IMPRESSION_TRACKING_PREF;
+ let DEFAULT_RECS_EXPIRE_TIME;
+ let instance;
+ let clock;
+ let globals;
+ let sectionsManagerStub;
+ let shortURLStub;
+
+ const FAKE_OPTIONS = {
+ stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
+ stories_referrer: "https://somedomain.org/referrer",
+ topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
+ survey_link: "https://www.surveymonkey.com/r/newtabffx",
+ api_key_pref: "apiKeyPref",
+ provider_name: "test-provider",
+ provider_icon: "provider-icon",
+ provider_description: "provider_desc",
+ };
+
+ beforeEach(() => {
+ FAKE_GLOBAL_PREFS.set("apiKeyPref", "test-api-key");
+ FAKE_GLOBAL_PREFS.set(
+ "pocketCta",
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ })
+ );
+
+ globals = new GlobalOverrider();
+ globals.set("PlacesUtils", { history: {} });
+ globals.set("pktApi", { isUserLoggedIn() {} });
+ clock = sinon.useFakeTimers();
+ shortURLStub = sinon.stub().callsFake(site => site.url);
+ sectionsManagerStub = {
+ onceInitialized: sinon.stub().callsFake(callback => callback()),
+ enableSection: sinon.spy(),
+ disableSection: sinon.spy(),
+ updateSection: sinon.spy(),
+ sections: new Map([["topstories", { options: FAKE_OPTIONS }]]),
+ };
+
+ ({
+ TopStoriesFeed,
+ STORIES_UPDATE_TIME,
+ TOPICS_UPDATE_TIME,
+ SECTION_ID,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ REC_IMPRESSION_TRACKING_PREF,
+ DEFAULT_RECS_EXPIRE_TIME,
+ } = injector({
+ "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs },
+ "lib/ShortURL.jsm": { shortURL: shortURLStub },
+ "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub },
+ }));
+
+ instance = new TopStoriesFeed();
+ instance.store = {
+ getState() {
+ return {
+ Prefs: {
+ values: {
+ showSponsored: true,
+ "feeds.section.topstories": true,
+ },
+ },
+ };
+ },
+ dispatch: sinon.spy(),
+ };
+ instance.storiesLastUpdated = 0;
+ instance.topicsLastUpdated = 0;
+ });
+ afterEach(() => {
+ globals.restore();
+ clock.restore();
+ });
+
+ describe("#lazyloading TopStories", () => {
+ beforeEach(() => {
+ instance.discoveryStreamEnabled = true;
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => {
+ instance.discoveryStreamEnabled = false;
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: true }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => {
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("Should initialize properties once while lazy loading if not initialized earlier", () => {
+ instance.discoveryStreamEnabled = false;
+ instance.propertiesInitialized = false;
+ sinon.stub(instance, "initializeProperties");
+ instance.lazyLoadTopStories();
+ assert.calledOnce(instance.initializeProperties);
+ });
+ it("should not re-initialize properties", () => {
+ // For discovery stream experience disabled TopStoriesFeed properties
+ // are initialized in constructor and should not be called again while lazy loading topstories
+ sinon.stub(instance, "initializeProperties");
+ instance.discoveryStreamEnabled = false;
+ instance.propertiesInitialized = true;
+ instance.lazyLoadTopStories();
+ assert.notCalled(instance.initializeProperties);
+ });
+ it("should have early exit onInit when discovery is true", async () => {
+ sinon.stub(instance, "doContentUpdate");
+ await instance.onInit();
+ assert.notCalled(instance.doContentUpdate);
+ assert.isUndefined(instance.storiesLoaded);
+ });
+ it("should complete onInit when discovery is false", async () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "doContentUpdate");
+ await instance.onInit();
+ assert.calledOnce(instance.doContentUpdate);
+ assert.isTrue(instance.storiesLoaded);
+ });
+ it("should handle limited actions when discoverystream is enabled", async () => {
+ sinon.spy(instance, "handleDisabled");
+ sinon.stub(instance, "getPocketState");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: true }),
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+
+ instance.onAction({ type: at.INIT, data: {} });
+
+ assert.calledOnce(instance.handleDisabled);
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.getPocketState);
+ });
+ it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.spy(instance, "handleDisabled");
+ sinon.stub(instance, "getPocketState");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.notCalled(instance.handleDisabled);
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.getPocketState);
+ });
+ it("should handle UNINIT when discoverystream is enabled", async () => {
+ sinon.stub(instance, "uninit");
+ instance.onAction({ type: at.UNINIT });
+ assert.calledOnce(instance.uninit);
+ });
+ it("should fire init on PREF_CHANGED", () => {
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fire init on DISCOVERY_STREAM_PREF_ENABLED", () => {
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.enabled", value: true },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should not fire init on PREF_CHANGED if stories are loaded", () => {
+ sinon.stub(instance, "onInit");
+ sinon.spy(instance, "lazyLoadTopStories");
+ instance.storiesLoaded = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ assert.notCalled(instance.onInit);
+ });
+ it("should fire init on PREF_CHANGED when discoverystream is disabled", () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "onInit");
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => {
+ instance.discoveryStreamEnabled = false;
+ sinon.stub(instance, "onInit");
+ sinon.spy(instance, "lazyLoadTopStories");
+ instance.storiesLoaded = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "discoverystream.config", value: {} },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ assert.notCalled(instance.onInit);
+ });
+ it("should not init props if ds pref is true", () => {
+ sinon.stub(instance, "initializeProperties");
+ instance.propertiesInitialized = false;
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "discoverystream.enabled": true,
+ "feeds.section.topstories": true,
+ },
+ },
+ });
+ instance.lazyLoadTopStories({
+ dsPref: JSON.stringify({ enabled: true }),
+ });
+ assert.notCalled(instance.initializeProperties);
+ });
+ it("should fire init if user pref is true", () => {
+ sinon.stub(instance, "onInit");
+ instance.store.getState = () => ({
+ Prefs: {
+ values: {
+ "discoverystream.config": JSON.stringify({ enabled: false }),
+ "discoverystream.enabled": false,
+ "feeds.section.topstories": false,
+ },
+ },
+ });
+ instance.lazyLoadTopStories({ userPref: true });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fire uninit if topstories update to false", () => {
+ sinon.stub(instance, "uninit");
+ instance.discoveryStreamEnabled = false;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: false,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledOnce(instance.uninit);
+ instance.discoveryStreamEnabled = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: false,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledTwice(instance.uninit);
+ });
+ it("should fire lazyLoadTopstories if topstories update to true", () => {
+ sinon.stub(instance, "lazyLoadTopStories");
+ instance.discoveryStreamEnabled = false;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: true,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledOnce(instance.lazyLoadTopStories);
+ instance.discoveryStreamEnabled = true;
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ value: true,
+ name: "feeds.section.topstories",
+ },
+ });
+ assert.calledTwice(instance.lazyLoadTopStories);
+ });
+ });
+
+ describe("#init", () => {
+ it("should create a TopStoriesFeed", () => {
+ assert.instanceOf(instance, TopStoriesFeed);
+ });
+ it("should bind parseOptions to SectionsManager.onceInitialized", () => {
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.onceInitialized);
+ });
+ it("should initialize endpoints based on options", async () => {
+ await instance.onInit();
+ assert.equal(
+ "https://somedomain.org/stories?key=test-api-key",
+ instance.stories_endpoint
+ );
+ assert.equal(
+ "https://somedomain.org/referrer",
+ instance.stories_referrer
+ );
+ assert.equal(
+ "https://somedomain.org/topics?key=test-api-key",
+ instance.topics_endpoint
+ );
+ });
+ it("should enable its section", () => {
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
+ });
+ it("init should fire onInit", () => {
+ instance.onInit = sinon.spy();
+ instance.onAction({ type: at.INIT, data: {} });
+ assert.calledOnce(instance.onInit);
+ });
+ it("should fetch stories on init", async () => {
+ instance.fetchStories = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics on init", async () => {
+ instance.fetchTopics = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.fetchTopics);
+ });
+ it("should not fetch if endpoint not configured", () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ sectionsManagerStub.sections.set("topstories", { options: {} });
+ instance.init();
+ assert.notCalled(fetchStub);
+ });
+ it("should report error for invalid configuration", () => {
+ globals.sandbox.spy(global.console, "error");
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ api_key_pref: "invalid",
+ stories_endpoint: "https://invalid.com/?apiKey=$apiKey",
+ },
+ });
+ instance.init();
+
+ assert.calledWith(
+ console.error,
+ "Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey"
+ );
+ });
+ it("should report error for missing api key", () => {
+ globals.sandbox.spy(global.console, "error");
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "https://somedomain.org/stories?key=$apiKey",
+ topics_endpoint: "https://somedomain.org/topics?key=$apiKey",
+ },
+ });
+ instance.init();
+
+ assert.called(console.error);
+ });
+ it("should load data from cache on init", async () => {
+ instance.loadCachedData = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.loadCachedData);
+ });
+ });
+ describe("#uninit", () => {
+ it("should disable its section", () => {
+ instance.onAction({ type: at.UNINIT });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
+ });
+ it("should unload stories on uninit", async () => {
+ sinon.stub(instance.cache, "set").returns(Promise.resolve());
+ await instance.clearCache();
+ assert.calledWith(instance.cache.set.firstCall, "stories", {});
+ assert.calledWith(instance.cache.set.secondCall, "topics", {});
+ assert.calledWith(instance.cache.set.thirdCall, "spocs", {});
+ });
+ });
+ describe("#cache", () => {
+ it("should clear all cache items when calling clearCache", () => {
+ sinon.stub(instance.cache, "set").returns(Promise.resolve());
+ instance.storiesLoaded = true;
+ instance.uninit();
+ assert.equal(instance.storiesLoaded, false);
+ });
+ it("should set spocs cache on fetch", async () => {
+ const response = {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ settings: {},
+ spocs: [{ id: "spoc1" }],
+ };
+
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ sinon.spy(instance.cache, "set");
+
+ await instance.fetchStories();
+
+ assert.calledOnce(instance.cache.set);
+ const { args } = instance.cache.set.firstCall;
+ assert.equal(args[0], "stories");
+ assert.equal(args[1].spocs[0].id, "spoc1");
+ });
+ it("should get spocs on cache load", async () => {
+ instance.cache.get = () => ({
+ stories: {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ spocs: [{ id: "spoc1" }],
+ },
+ });
+ instance.storiesLastUpdated = 0;
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+
+ await instance.loadCachedData();
+ assert.equal(instance.spocs[0].guid, "spoc1");
+ });
+ });
+ describe("#fetch", () => {
+ it("should fetch stories, send event and cache results", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "stories-endpoint",
+ stories_referrer: "referrer",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ },
+ ],
+ };
+ const stories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "rec-url",
+ score: 1,
+ spoc_meta: {},
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledOnce(shortURLStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
+ credentials: "omit",
+ });
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: stories,
+ });
+ assert.calledOnce(instance.cache.set);
+ assert.calledWith(
+ instance.cache.set,
+ "stories",
+ Object.assign({}, response, { _timestamp: 0 })
+ );
+ });
+ it("should use domain as hostname, if present", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ stories_endpoint: "stories-endpoint",
+ stories_referrer: "referrer",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ domain: "domain",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ },
+ ],
+ };
+ const stories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "domain",
+ score: 1,
+ spoc_meta: {},
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.notCalled(shortURLStub);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: stories,
+ });
+ });
+ it("should call SectionsManager.updateSection", () => {
+ instance.dispatchUpdateEvent(123, {});
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ });
+ it("should report error for unexpected stories response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.console, "error");
+
+ fetchStub.resolves({ ok: false, status: 400 });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.stories_endpoint, {
+ credentials: "omit",
+ });
+ assert.equal(instance.storiesLastUpdated, 0);
+ assert.called(console.error);
+ });
+ it("should exclude blocked (dismissed) URLs", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: site => site.url === "blocked" },
+ });
+
+ const response = {
+ recommendations: [{ url: "blocked" }, { url: "not_blocked" }],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ // Issue!
+ // Should actually be fixed when cache is fixed.
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
+ 1
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url,
+ "not_blocked"
+ );
+ });
+ it("should mark stories as new", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_endpoint: "stories-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ clock.restore();
+ const response = {
+ recommendations: [
+ { published_timestamp: Date.now() / 1000 },
+ { published_timestamp: "0" },
+ {
+ published_timestamp: (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000,
+ },
+ ],
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.onInit();
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows.length,
+ 3
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type,
+ "now"
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type,
+ "trending"
+ );
+ assert.equal(
+ sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type,
+ "trending"
+ );
+ });
+ it("should fetch topics, send event and cache results", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: { topics_endpoint: "topics-endpoint" },
+ });
+ globals.set("fetch", fetchStub);
+
+ const response = {
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ };
+ const topics = [
+ {
+ name: "topic1",
+ url: "url-topic1",
+ },
+ {
+ name: "topic2",
+ url: "url-topic2",
+ },
+ ];
+
+ instance.cache.set = sinon.spy();
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
+ credentials: "omit",
+ });
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {
+ topics,
+ });
+ assert.calledOnce(instance.cache.set);
+ assert.calledWith(
+ instance.cache.set,
+ "topics",
+ Object.assign({}, response, { _timestamp: 0 })
+ );
+ });
+ it("should report error for unexpected topics response", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.sandbox.spy(global.console, "error");
+
+ instance.topics_endpoint = "topics-endpoint";
+ fetchStub.resolves({ ok: false, status: 400 });
+ await instance.fetchTopics();
+
+ assert.calledOnce(fetchStub);
+ assert.calledWithExactly(fetchStub, instance.topics_endpoint, {
+ credentials: "omit",
+ });
+ assert.notCalled(instance.store.dispatch);
+ assert.called(console.error);
+ });
+ });
+ describe("#personalization", () => {
+ it("should sort stories", async () => {
+ const response = {
+ recommendations: [{ id: "1" }, { id: "2" }],
+ settings: {},
+ };
+
+ instance.compareScore = sinon.spy();
+ instance.stories_endpoint = "stories-endpoint";
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.fetchStories();
+ assert.calledOnce(instance.compareScore);
+ });
+ it("should sort items based on relevance score", () => {
+ let items = [{ score: 0.1 }, { score: 0.2 }];
+ items = items.sort(instance.compareScore);
+ assert.deepEqual(items, [{ score: 0.2 }, { score: 0.1 }]);
+ });
+ it("should rotate items", () => {
+ let items = [
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ { guid: "g4" },
+ { guid: "g5" },
+ { guid: "g6" },
+ ];
+
+ // No impressions should leave items unchanged
+ let rotated = instance.rotate(items);
+ assert.deepEqual(items, rotated);
+
+ // Recent impression should leave items unchanged
+ instance._prefs.get = pref =>
+ pref === REC_IMPRESSION_TRACKING_PREF &&
+ JSON.stringify({ g1: 1, g2: 1, g3: 1 });
+ rotated = instance.rotate(items);
+ assert.deepEqual(items, rotated);
+
+ // Impression older than expiration time should rotate items
+ clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1);
+ rotated = instance.rotate(items);
+ assert.deepEqual(
+ [
+ { guid: "g4" },
+ { guid: "g5" },
+ { guid: "g6" },
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ ],
+ rotated
+ );
+
+ instance._prefs.get = pref =>
+ pref === REC_IMPRESSION_TRACKING_PREF &&
+ JSON.stringify({
+ g1: 1,
+ g2: 1,
+ g3: 1,
+ g4: DEFAULT_RECS_EXPIRE_TIME + 1,
+ });
+ clock.tick(DEFAULT_RECS_EXPIRE_TIME);
+ rotated = instance.rotate(items);
+ assert.deepEqual(
+ [
+ { guid: "g5" },
+ { guid: "g6" },
+ { guid: "g1" },
+ { guid: "g2" },
+ { guid: "g3" },
+ { guid: "g4" },
+ ],
+ rotated
+ );
+ });
+ it("should record top story impressions", async () => {
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+
+ clock.tick(1);
+ let expectedPrefValue = JSON.stringify({ 1: 1, 2: 1, 3: 1 });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+
+ // Only need to record first impression, so impression pref shouldn't change
+ instance._prefs.get = pref => expectedPrefValue;
+ clock.tick(1);
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 1 }, { id: 2 }, { id: 3 }],
+ },
+ });
+ assert.calledOnce(instance._prefs.set);
+
+ // New first impressions should be added
+ clock.tick(1);
+ let expectedPrefValueTwo = JSON.stringify({
+ 1: 1,
+ 2: 1,
+ 3: 1,
+ 4: 3,
+ 5: 3,
+ 6: 3,
+ });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 4 }, { id: 5 }, { id: 6 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.secondCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueTwo
+ );
+ });
+ it("should not record top story impressions for non-view impressions", async () => {
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+ });
+ it("should clean up top story impressions", async () => {
+ instance._prefs = {
+ get: pref => JSON.stringify({ 1: 1, 2: 1, 3: 1 }),
+ set: sinon.spy(),
+ };
+
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ instance.stories_endpoint = "stories-endpoint";
+ const response = { recommendations: [{ id: 3 }, { id: 4 }, { id: 5 }] };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ // Should remove impressions for rec 1 and 2 as no longer in the feed
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 3: 1 })
+ );
+ });
+ it("should not change provider with badly formed JSON", async () => {
+ sinon.stub(instance, "uninit");
+ sinon.stub(instance, "init");
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ await instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "feeds.section.topstories.options",
+ value: "{version: 2}",
+ },
+ });
+ assert.notCalled(instance.uninit);
+ assert.notCalled(instance.init);
+ assert.notCalled(instance.clearCache);
+ });
+ });
+ describe("#spocs", async () => {
+ it("should not display expired or untimestamped spocs", async () => {
+ clock.tick(441792000000); // 01/01/1984
+
+ instance.spocsPerNewTabs = 1;
+ instance.show_spocs = true;
+ instance.isBelowFrequencyCap = () => true;
+
+ // NOTE: `expiration_timestamp` is seconds since UNIX epoch
+ instance.spocs = [
+ // No timestamp stays visible
+ {
+ id: "spoc1",
+ },
+ // Expired spoc gets filtered out
+ {
+ id: "spoc2",
+ expiration_timestamp: 1,
+ },
+ // Far future expiration spoc stays visible
+ {
+ id: "spoc3",
+ expiration_timestamp: 32503708800, // 01/01/3000
+ },
+ ];
+
+ sinon.spy(instance, "filterSpocs");
+
+ instance.filterSpocs();
+
+ assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2);
+ assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1");
+ assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3");
+ });
+ it("should insert spoc with provided probability", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ // Include spocs with a expiration in the very distant future
+ spocs: [
+ { id: "spoc1", expiration_timestamp: 9999999999999 },
+ { id: "spoc2", expiration_timestamp: 9999999999999 },
+ ],
+ };
+
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+ instance.storiesLoaded = true;
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ });
+ instance.dispatchSpocDone = () => {};
+ instance.getPocketState = () => {};
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.store.dispatch);
+ let [action] = instance.store.dispatch.firstCall.args;
+
+ assert.equal(at.SECTION_UPDATE, action.type);
+ assert.equal(true, action.meta.skipMain);
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
+ assert.equal(action.data.rows[2].pinned, true);
+
+ // Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5)
+ globals.set("Math", {
+ random: () => 0.6,
+ min: Math.min,
+ });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.store.dispatch);
+
+ globals.set("Math", {
+ random: () => 0.3,
+ min: Math.min,
+ });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledTwice(instance.store.dispatch);
+ [action] = instance.store.dispatch.secondCall.args;
+ assert.equal(at.SECTION_UPDATE, action.type);
+ assert.equal(true, action.meta.skipMain);
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh
+ assert.equal(action.data.rows[2].pinned, true);
+ });
+ it("should delay inserting spoc if stories haven't been fetched", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ floor: Math.floor,
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
+ // Include one spoc with a expiration in the very distant future
+ spocs: [
+ { id: "spoc1", expiration_timestamp: 9999999999999 },
+ { id: "spoc2" },
+ ],
+ };
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ assert.equal(instance.contentUpdateQueue.length, 1);
+
+ instance.spocsPerNewTabs = 0.5;
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+
+ await instance.onInit();
+ assert.equal(instance.contentUpdateQueue.length, 0);
+ assert.calledOnce(instance.store.dispatch);
+ let [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, at.SECTION_UPDATE);
+ });
+ it("should not insert spoc if preffed off", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: false,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [{ id: "spoc1" }, { id: "spoc2" }],
+ };
+ sinon.spy(instance, "maybeAddSpoc");
+ sinon.spy(instance, "shouldShowSpocs");
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.maybeAddSpoc);
+ assert.calledOnce(instance.shouldShowSpocs);
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should call dispatchSpocDone when calling maybeAddSpoc", async () => {
+ instance.dispatchSpocDone = sinon.spy();
+ instance.storiesLoaded = true;
+ await instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.dispatchSpocDone);
+ assert.calledWith(instance.dispatchSpocDone, {});
+ });
+ it("should fire POCKET_WAITING_FOR_SPOC action with false", () => {
+ instance.dispatchSpocDone({});
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_WAITING_FOR_SPOC");
+ assert.equal(action.data, false);
+ });
+ it("should not insert spoc if user opted out", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [{ id: "spoc1" }, { id: "spoc2" }],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: false } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should not fail if there is no spoc", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }],
+ };
+
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ it("should record spoc/campaign impressions for frequency capping", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ globals.set("Math", {
+ random: () => 0.4,
+ min: Math.min,
+ floor: Math.floor,
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ let expectedPrefValue = JSON.stringify({ 5: [0] });
+ let expectedPrefValueCallTwo = JSON.stringify({ 2: 0, 3: 0 });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.firstCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+ assert.calledWith(
+ instance._prefs.set.secondCall,
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallTwo
+ );
+
+ clock.tick(1);
+ instance._prefs.get = pref => expectedPrefValue;
+ let expectedPrefValueCallThree = JSON.stringify({ 5: [0, 1] });
+ let expectedPrefValueCallFour = JSON.stringify({ 2: 1, 3: 1, 5: [0] });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.thirdCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallThree
+ );
+ assert.calledWith(
+ instance._prefs.set.getCall(3),
+ REC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValueCallFour
+ );
+
+ clock.tick(1);
+ instance._prefs.get = pref => expectedPrefValueCallThree;
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
+ },
+ });
+ assert.calledWith(
+ instance._prefs.set.getCall(4),
+ SPOC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 5: [0, 1], 6: [2] })
+ );
+ assert.calledWith(
+ instance._prefs.set.getCall(5),
+ REC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 2: 2, 3: 2, 5: [0, 1] })
+ );
+ });
+ it("should not record spoc/campaign impressions for non-view impressions", async () => {
+ let fetchStub = globals.sandbox.stub();
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] },
+ });
+ assert.notCalled(instance._prefs.set);
+ });
+ it("should clean up spoc/campaign impressions", async () => {
+ let fetchStub = globals.sandbox.stub();
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ instance._prefs = { get: pref => undefined, set: sinon.spy() };
+ instance.show_spocs = true;
+ instance.stories_endpoint = "stories-endpoint";
+
+ const response = {
+ settings: { spocsPerNewTabs: 0.5 },
+ spocs: [
+ { id: 1, campaign_id: 5 },
+ { id: 4, campaign_id: 6 },
+ ],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.fetchStories();
+
+ // simulate impressions for campaign 5 and 6
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 1 }],
+ },
+ });
+ instance._prefs.get = pref =>
+ pref === SPOC_IMPRESSION_TRACKING_PREF && JSON.stringify({ 5: [0] });
+ instance.onAction({
+ type: at.TELEMETRY_IMPRESSION_STATS,
+ data: {
+ source: "TOP_STORIES",
+ tiles: [{ id: 3 }, { id: 2 }, { id: 4 }],
+ },
+ });
+
+ let expectedPrefValue = JSON.stringify({ 5: [0], 6: [0] });
+ assert.calledWith(
+ instance._prefs.set.thirdCall,
+ SPOC_IMPRESSION_TRACKING_PREF,
+ expectedPrefValue
+ );
+ instance._prefs.get = pref =>
+ pref === SPOC_IMPRESSION_TRACKING_PREF && expectedPrefValue;
+
+ // remove campaign 5 from response
+ const updatedResponse = {
+ settings: { spocsPerNewTabs: 1 },
+ spocs: [{ id: 4, campaign_id: 6 }],
+ };
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(updatedResponse),
+ });
+ await instance.fetchStories();
+
+ // should remove campaign 5 from pref as no longer active
+ assert.calledWith(
+ instance._prefs.set.getCall(4),
+ SPOC_IMPRESSION_TRACKING_PREF,
+ JSON.stringify({ 6: [0] })
+ );
+ });
+ it("should maintain frequency caps when inserting spocs", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ const response = {
+ settings: { spocsPerNewTabs: 1 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ spocs: [
+ // Set spoc `expiration_timestamp`s in the very distant future to ensure they show up
+ {
+ id: "spoc1",
+ campaign_id: 1,
+ caps: { lifetime: 3, campaign: { count: 2, period: 3600 } },
+ expiration_timestamp: 999999999999,
+ },
+ {
+ id: "spoc2",
+ campaign_id: 2,
+ caps: { lifetime: 1 },
+ expiration_timestamp: 999999999999,
+ },
+ ],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+ instance.spocsPerNewTabs = 1;
+
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ let [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1] });
+
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.secondCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1, 2] });
+
+ // campaign 1 period frequency cap now reached (spoc 2 should be shown)
+ clock.tick();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.thirdCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc2");
+ instance._prefs.get = pref => JSON.stringify({ 1: [1, 2], 2: [3] });
+
+ // new campaign 1 period starting (spoc 1 sohuld be shown again)
+ clock.tick(2 * 60 * 60 * 1000);
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ [action] = instance.store.dispatch.lastCall.args;
+ assert.equal(action.data.rows[0].guid, "rec1");
+ assert.equal(action.data.rows[1].guid, "rec2");
+ assert.equal(action.data.rows[2].guid, "spoc1");
+ instance._prefs.get = pref =>
+ JSON.stringify({ 1: [1, 2, 7200003], 2: [3] });
+
+ // campaign 1 lifetime cap now reached (no spoc should be sent)
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.callCount(instance.store.dispatch, 4);
+ });
+ it("should maintain client-side MAX_LIFETIME_CAP", async () => {
+ let fetchStub = globals.sandbox.stub();
+ instance.dispatchSpocDone = () => {};
+ sectionsManagerStub.sections.set("topstories", {
+ options: {
+ show_spocs: true,
+ stories_endpoint: "stories-endpoint",
+ },
+ });
+ globals.set("fetch", fetchStub);
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ instance.getPocketState = () => {};
+ instance.dispatchPocketCta = () => {};
+
+ const response = {
+ settings: { spocsPerNewTabs: 1 },
+ recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ spocs: [{ id: "spoc1", campaign_id: 1, caps: { lifetime: 501 } }],
+ };
+
+ instance.store.getState = () => ({
+ Sections: [{ id: "topstories", rows: response.recommendations }],
+ Prefs: { values: { showSponsored: true } },
+ });
+ fetchStub.resolves({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(response),
+ });
+ await instance.onInit();
+
+ instance._prefs.get = pref =>
+ JSON.stringify({ 1: [...Array(500).keys()] });
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.notCalled(instance.store.dispatch);
+ });
+ });
+ describe("#update", () => {
+ it("should fetch stories after update interval", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "fetchStories");
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(instance.fetchStories);
+
+ clock.tick(STORIES_UPDATE_TIME);
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(instance.fetchStories);
+ });
+ it("should fetch topics after update interval", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "fetchTopics");
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.notCalled(instance.fetchTopics);
+
+ clock.tick(TOPICS_UPDATE_TIME);
+ await instance.onAction({ type: at.SYSTEM_TICK });
+ assert.calledOnce(instance.fetchTopics);
+ });
+ it("should return updated stories and topics on system tick", async () => {
+ await instance.onInit();
+ sinon.spy(instance, "dispatchUpdateEvent");
+ const stories = [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }];
+ const topics = [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ];
+ clock.tick(TOPICS_UPDATE_TIME);
+ globals.sandbox.stub(instance, "fetchStories").resolves(stories);
+ globals.sandbox.stub(instance, "fetchTopics").resolves(topics);
+
+ await instance.onAction({ type: at.SYSTEM_TICK });
+
+ assert.calledOnce(instance.dispatchUpdateEvent);
+ assert.calledWith(instance.dispatchUpdateEvent, false, {
+ rows: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }],
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ read_more_endpoint: undefined,
+ });
+ });
+ it("should not call init and uninit if data doesn't match on options change ", () => {
+ sinon.spy(instance, "init");
+ sinon.spy(instance, "uninit");
+ instance.onAction({ type: at.SECTION_OPTIONS_CHANGED, data: "foo" });
+ assert.notCalled(sectionsManagerStub.disableSection);
+ assert.notCalled(sectionsManagerStub.enableSection);
+ assert.notCalled(instance.init);
+ assert.notCalled(instance.uninit);
+ });
+ it("should call init and uninit on options change", async () => {
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ sinon.spy(instance, "init");
+ sinon.spy(instance, "uninit");
+ await instance.onAction({
+ type: at.SECTION_OPTIONS_CHANGED,
+ data: "topstories",
+ });
+ assert.calledOnce(sectionsManagerStub.disableSection);
+ assert.calledOnce(sectionsManagerStub.enableSection);
+ assert.calledOnce(instance.clearCache);
+ assert.calledOnce(instance.init);
+ assert.calledOnce(instance.uninit);
+ });
+ it("should set LastUpdated to 0 on init", async () => {
+ instance.storiesLastUpdated = 1;
+ instance.topicsLastUpdated = 1;
+
+ await instance.onInit();
+ assert.equal(instance.storiesLastUpdated, 0);
+ assert.equal(instance.topicsLastUpdated, 0);
+ });
+ it("should filter spocs when link is blocked", async () => {
+ instance.spocs = [{ url: "not_blocked" }, { url: "blocked" }];
+ await instance.onAction({
+ type: at.PLACES_LINK_BLOCKED,
+ data: { url: "blocked" },
+ });
+
+ assert.deepEqual(instance.spocs, [{ url: "not_blocked" }]);
+ });
+ });
+ describe("#loadCachedData", () => {
+ it("should update section with cached stories and topics if available", async () => {
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_referrer: "referrer" },
+ });
+ const stories = {
+ _timestamp: 123,
+ recommendations: [
+ {
+ id: "1",
+ title: "title",
+ excerpt: "description",
+ image_src: "image-url",
+ url: "rec-url",
+ published_timestamp: "123",
+ context: "trending",
+ icon: "icon",
+ item_score: 0.98,
+ },
+ ],
+ };
+ const transformedStories = [
+ {
+ guid: "1",
+ type: "now",
+ title: "title",
+ context: "trending",
+ icon: "icon",
+ description: "description",
+ image: "image-url",
+ referrer: "referrer",
+ url: "rec-url",
+ hostname: "rec-url",
+ score: 0.98,
+ spoc_meta: {},
+ },
+ ];
+ const topics = {
+ _timestamp: 123,
+ topics: [
+ { name: "topic1", url: "url-topic1" },
+ { name: "topic2", url: "url-topic2" },
+ ],
+ };
+ instance.cache.get = () => ({ stories, topics });
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+
+ await instance.onInit();
+ assert.calledOnce(sectionsManagerStub.updateSection);
+ assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {
+ rows: transformedStories,
+ topics: topics.topics,
+ read_more_endpoint: undefined,
+ });
+ });
+ it("should NOT update section if there is no cached data", async () => {
+ instance.cache.get = () => ({});
+ globals.set("NewTabUtils", {
+ blockedLinks: { isBlocked: globals.sandbox.spy() },
+ });
+ await instance.loadCachedData();
+ assert.notCalled(sectionsManagerStub.updateSection);
+ });
+ it("should use store rows if no stories sent to doContentUpdate", async () => {
+ instance.store = {
+ getState() {
+ return {
+ Sections: [{ id: "topstories", rows: [1, 2, 3] }],
+ };
+ },
+ };
+ sinon.spy(instance, "dispatchUpdateEvent");
+
+ instance.doContentUpdate({}, false);
+
+ assert.calledOnce(instance.dispatchUpdateEvent);
+ assert.calledWith(instance.dispatchUpdateEvent, false, {
+ rows: [1, 2, 3],
+ });
+ });
+ it("should broadcast in doContentUpdate when updating from cache", async () => {
+ sectionsManagerStub.sections.set("topstories", {
+ options: { stories_referrer: "referrer" },
+ });
+ globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } });
+ const stories = { recommendations: [{}] };
+ const topics = { topics: [{}] };
+ sinon.spy(instance, "doContentUpdate");
+ instance.cache.get = () => ({ stories, topics });
+ await instance.onInit();
+ assert.calledOnce(instance.doContentUpdate);
+ assert.calledWith(
+ instance.doContentUpdate,
+ {
+ stories: [
+ {
+ context: undefined,
+ description: undefined,
+ guid: undefined,
+ hostname: undefined,
+ icon: undefined,
+ image: undefined,
+ referrer: "referrer",
+ score: 1,
+ spoc_meta: {},
+ title: undefined,
+ type: "trending",
+ url: undefined,
+ },
+ ],
+ topics: [{}],
+ },
+ true
+ );
+ });
+ });
+ describe("#pocket", () => {
+ it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => {
+ instance.getPocketState = sinon.spy();
+ instance.onAction({
+ type: at.NEW_TAB_REHYDRATED,
+ meta: { fromTarget: {} },
+ });
+ assert.calledOnce(instance.getPocketState);
+ assert.calledWith(instance.getPocketState, {});
+ });
+ it("should call dispatch in getPocketState", () => {
+ const isUserLoggedIn = sinon.spy();
+ globals.set("pktApi", { isUserLoggedIn });
+ instance.getPocketState({});
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_LOGGED_IN");
+ assert.calledOnce(isUserLoggedIn);
+ });
+ it("should call dispatchPocketCta when hitting onInit", async () => {
+ instance.dispatchPocketCta = sinon.spy();
+ await instance.onInit();
+ assert.calledOnce(instance.dispatchPocketCta);
+ assert.calledWith(
+ instance.dispatchPocketCta,
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ false
+ );
+ });
+ it("should call dispatch in dispatchPocketCta", () => {
+ instance.dispatchPocketCta(JSON.stringify({ use_cta: true }), false);
+ assert.calledOnce(instance.store.dispatch);
+ const [action] = instance.store.dispatch.firstCall.args;
+ assert.equal(action.type, "POCKET_CTA");
+ assert.equal(action.data.use_cta, true);
+ });
+ it("should call dispatchPocketCta with a pocketCta pref change", () => {
+ instance.dispatchPocketCta = sinon.spy();
+ instance.onAction({
+ type: at.PREF_CHANGED,
+ data: {
+ name: "pocketCta",
+ value: JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ },
+ });
+ assert.calledOnce(instance.dispatchPocketCta);
+ assert.calledWith(
+ instance.dispatchPocketCta,
+ JSON.stringify({
+ cta_button: "",
+ cta_text: "",
+ cta_url: "",
+ use_cta: false,
+ }),
+ true
+ );
+ });
+ });
+ it("should call uninit and init on disabling of showSponsored pref", async () => {
+ sinon.stub(instance, "clearCache").returns(Promise.resolve());
+ sinon.stub(instance, "uninit");
+ sinon.stub(instance, "init");
+ await instance.onAction({
+ type: at.PREF_CHANGED,
+ data: { name: "showSponsored", value: false },
+ });
+ assert.calledOnce(instance.clearCache);
+ assert.calledOnce(instance.uninit);
+ assert.calledOnce(instance.init);
+ });
+});
diff --git a/browser/components/newtab/test/unit/lib/UTEventReporting.test.js b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js
new file mode 100644
index 0000000000..6255568438
--- /dev/null
+++ b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js
@@ -0,0 +1,115 @@
+import { UTSessionPing, UTUserEventPing } from "test/schemas/pings";
+import { GlobalOverrider } from "test/unit/utils";
+import { UTEventReporting } from "lib/UTEventReporting.sys.mjs";
+
+const FAKE_EVENT_PING_PC = {
+ event: "CLICK",
+ source: "TOP_SITES",
+ addon_version: "123",
+ user_prefs: 63,
+ session_id: "abc",
+ page: "about:newtab",
+ action_position: 5,
+ locale: "en-US",
+};
+const FAKE_SESSION_PING_PC = {
+ session_duration: 1234,
+ addon_version: "123",
+ user_prefs: 63,
+ session_id: "abc",
+ page: "about:newtab",
+ locale: "en-US",
+};
+const FAKE_EVENT_PING_UT = [
+ "activity_stream",
+ "event",
+ "CLICK",
+ "TOP_SITES",
+ {
+ addon_version: "123",
+ user_prefs: "63",
+ session_id: "abc",
+ page: "about:newtab",
+ action_position: "5",
+ },
+];
+const FAKE_SESSION_PING_UT = [
+ "activity_stream",
+ "end",
+ "session",
+ "1234",
+ {
+ addon_version: "123",
+ user_prefs: "63",
+ session_id: "abc",
+ page: "about:newtab",
+ },
+];
+
+describe("UTEventReporting", () => {
+ let globals;
+ let sandbox;
+ let utEvents;
+
+ beforeEach(() => {
+ globals = new GlobalOverrider();
+ sandbox = globals.sandbox;
+ sandbox.stub(global.Services.telemetry, "setEventRecordingEnabled");
+ sandbox.stub(global.Services.telemetry, "recordEvent");
+
+ utEvents = new UTEventReporting();
+ });
+
+ afterEach(() => {
+ globals.restore();
+ });
+
+ describe("#sendUserEvent()", () => {
+ it("should queue up the correct data to send to Events Telemetry", async () => {
+ utEvents.sendUserEvent(FAKE_EVENT_PING_PC);
+ assert.calledWithExactly(
+ global.Services.telemetry.recordEvent,
+ ...FAKE_EVENT_PING_UT
+ );
+
+ let ping = global.Services.telemetry.recordEvent.firstCall.args;
+ assert.validate(ping, UTUserEventPing);
+ });
+ });
+
+ describe("#sendSessionEndEvent()", () => {
+ it("should queue up the correct data to send to Events Telemetry", async () => {
+ utEvents.sendSessionEndEvent(FAKE_SESSION_PING_PC);
+ assert.calledWithExactly(
+ global.Services.telemetry.recordEvent,
+ ...FAKE_SESSION_PING_UT
+ );
+
+ let ping = global.Services.telemetry.recordEvent.firstCall.args;
+ assert.validate(ping, UTSessionPing);
+ });
+ });
+
+ describe("#uninit()", () => {
+ it("should call setEventRecordingEnabled with a false value", () => {
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.firstCall.args[0],
+ "activity_stream"
+ );
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.firstCall.args[1],
+ true
+ );
+
+ utEvents.uninit();
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.secondCall.args[0],
+ "activity_stream"
+ );
+ assert.equal(
+ global.Services.telemetry.setEventRecordingEnabled.secondCall.args[1],
+ false
+ );
+ });
+ });
+});