diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/newtab/test/unit/lib | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/unit/lib')
27 files changed, 11732 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..7438d8247c --- /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.sys.mjs"; +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..c127060021 --- /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.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +import { DEFAULT_SITES } from "lib/DefaultSites.sys.mjs"; +import { AboutPreferences } from "lib/AboutPreferences.sys.mjs"; +import { DefaultPrefs } from "lib/ActivityStreamPrefs.sys.mjs"; +import { NewTabInit } from "lib/NewTabInit.sys.mjs"; +import { SectionsFeed } from "lib/SectionsManager.sys.mjs"; +import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs"; +import { PlacesFeed } from "lib/PlacesFeed.sys.mjs"; +import { PrefsFeed } from "lib/PrefsFeed.sys.mjs"; +import { SystemTickFeed } from "lib/SystemTickFeed.sys.mjs"; +import { TelemetryFeed } from "lib/TelemetryFeed.sys.mjs"; +import { FaviconFeed } from "lib/FaviconFeed.sys.mjs"; +import { TopSitesFeed } from "lib/TopSitesFeed.sys.mjs"; +import { TopStoriesFeed } from "lib/TopStoriesFeed.sys.mjs"; +import { HighlightsFeed } from "lib/HighlightsFeed.sys.mjs"; +import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.sys.mjs"; + +import { LinksCache } from "lib/LinksCache.sys.mjs"; +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; +import { DownloadsManager } from "lib/DownloadsManager.sys.mjs"; + +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..4bea86331d --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js @@ -0,0 +1,445 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + ActivityStreamMessageChannel, + DEFAULT_OPTIONS, +} from "lib/ActivityStreamMessageChannel.sys.mjs"; +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() {} }); + globals.set("AboutNewTabParent", { + flushQueuedMessagesFromContent: globals.sandbox.stub(), + }); + + 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); + }); + it("should flush queued messages from content when doing the simulation", () => { + assert.notCalled( + global.AboutNewTabParent.flushQueuedMessagesFromContent + ); + mm.simulateMessagesForExistingTabs(); + assert.calledOnce( + global.AboutNewTabParent.flushQueuedMessagesFromContent + ); + }); + }); + }); + 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..bff1708ef7 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js @@ -0,0 +1,113 @@ +import { DefaultPrefs, Prefs } from "lib/ActivityStreamPrefs.sys.mjs"; + +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..0b8baef762 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js @@ -0,0 +1,161 @@ +import { ActivityStreamStorage } from "lib/ActivityStreamStorage.sys.mjs"; +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..92e10facb3 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -0,0 +1,3523 @@ +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.sys.mjs"; +import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs"; +import { reducers } from "common/Reducers.sys.mjs"; + +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; + +const CONFIG_PREF_NAME = "discoverystream.config"; +const ENDPOINTS_PREF_NAME = "discoverystream.endpoints"; +const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy"; +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, + }); + + 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, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "system.showSponsored": false, + }, + }, + }); + 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 use local basic layout with hardcoded_basic_layout being true", async () => { + feed.config.hardcoded_basic_layout = true; + + await feed.loadLayout(feed.store.dispatch); + + 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.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": true, + "system.showSponsored": false, + }, + }, + }); + + 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.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": false, + "system.showSponsored": false, + }, + }, + }); + + 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 FF pref hardcoded_basic_layout", async () => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.hardcoded-basic-layout": true, + "system.showSponsored": false, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + 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, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.spocs-endpoint": + "https://spocs.getpocket.com/spocs2", + "system.showSponsored": false, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs2" + ); + }); + it("should return enough stories to fill a four card layout", async () => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { fourCardLayout: true }, + }, + }, + }); + + 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.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.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.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocTopsitesPlacementEnabled: true, + 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.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, "system.showSponsored": 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, + "system.showSponsored": 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("#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: [], personalized: false })); + 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: [], personalized: false })); + 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("#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.calledWith(feed.cache.set, "spocs", { lastUpdated: 0, spocs: {} }); + }); + 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: { + personalized: false, + 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 dispatch DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE with feature_flags", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.spy(feed.store, "dispatch"); + 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.calledWith( + feed.store.dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE, + data: { + override: true, + }, + }) + ); + }); + 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: { + personalized: false, + 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: { + personalized: false, + title: "title", + context: "context", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + }); + }); + describe("test SOV behaviour", () => { + beforeEach(() => { + globals.set("NimbusFeatures", { + pocketNewtab: { + getVariable: sandbox.stub(), + }, + }); + global.NimbusFeatures.pocketNewtab.getVariable + .withArgs("topSitesContileSovEnabled") + .returns(true); + // We don't need this for just this test, we are setting placements + // manually. + feed.getPlacements.restore(); + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + const fakeComponents = { + components: [ + { placement: { name: "sponsored-topsites" }, spocs: {} }, + { placement: { name: "spocs" }, spocs: {} }, + ], + }; + feed.updatePlacements(feed.store.dispatch, [fakeComponents]); + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + spocs: [{ id: "spoc1" }], + "sponsored-topsites": [{ id: "topsite1" }], + }); + }); + it("should use topsites placement by default if there is no SOV", async () => { + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].body, + JSON.stringify({ + pocket_id: "{foo-123-foo}", + version: 2, + placements: [ + { + name: "sponsored-topsites", + }, + { + name: "spocs", + }, + ], + }) + ); + }); + it("should use cache if cache is available and SOV is not ready", async () => { + const cache = { + sov: [{ assignedPartner: "amp" }], + }; + feed.cache.get.resolves(cache); + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].body, + JSON.stringify({ + pocket_id: "{foo-123-foo}", + version: 2, + placements: [ + { + name: "spocs", + }, + ], + }) + ); + }); + it("should properly set placements", async () => { + sandbox.spy(feed.cache, "set"); + + // Testing only 1 placement type. + feed.store.dispatch( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { + position: 1, + assignedPartner: "amp", + }, + { + position: 2, + assignedPartner: "amp", + }, + ], + }, + }) + ); + + await feed.loadSpocs(feed.store.dispatch); + + const firstCall = feed.cache.set.getCall(0); + assert.deepEqual(firstCall.args[0], "sov"); + assert.deepEqual(firstCall.args[1], [ + { + position: 1, + assignedPartner: "amp", + }, + { + position: 2, + assignedPartner: "amp", + }, + ]); + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].body, + JSON.stringify({ + pocket_id: "{foo-123-foo}", + version: 2, + placements: [ + { + name: "spocs", + }, + ], + }) + ); + + // Testing 2 placement types. + feed.store.dispatch( + ac.OnlyToMain({ + type: at.SOV_UPDATED, + data: { + ready: true, + positions: [ + { + position: 1, + assignedPartner: "amp", + }, + { + position: 2, + assignedPartner: "moz-sales", + }, + ], + }, + }) + ); + + await feed.loadSpocs(feed.store.dispatch); + + const secondCall = feed.cache.set.getCall(2); + assert.deepEqual(secondCall.args[0], "sov"); + assert.deepEqual(secondCall.args[1], [ + { + position: 1, + assignedPartner: "amp", + }, + { + position: 2, + assignedPartner: "moz-sales", + }, + ]); + assert.equal( + feed.fetchFromEndpoint.secondCall.args[1].body, + JSON.stringify({ + pocket_id: "{foo-123-foo}", + version: 2, + placements: [ + { + name: "sponsored-topsites", + }, + { + name: "spocs", + }, + ], + }) + ); + }); + }); + }); + + 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, "system.showSponsored": true }, + }, + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return false from showSponsoredStories if DiscoveryStream pref system.showSponsored is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { showSponsored: true, "system.showSponsored": false }, + }, + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return true from showSponsoredStories if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { showSponsored: true, "system.showSponsored": 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(() => { + 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, + "system.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: { PREF_SPOCS_CLEAR_ENDPOINT: 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); + }); + }); + + describe("#resetCache", () => { + it("should set .feeds .spocs and .sov to {}", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.resetCache(); + + assert.callCount(feed.cache.set, 3); + const firstCall = feed.cache.set.getCall(0); + const secondCall = feed.cache.set.getCall(1); + const thirdCall = feed.cache.set.getCall(2); + assert.deepEqual(firstCall.args, ["feeds", {}]); + assert.deepEqual(secondCall.args, ["spocs", {}]); + assert.deepEqual(thirdCall.args, ["sov", {}]); + }); + }); + + 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: "api_key_pref", value: "foo" }, + }); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: CONFIG_PREF_NAME, + value: JSON.stringify({ + enabled: true, + other: "value", + api_key_pref: "foo", + }), + }, + 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, + api_key_pref: "foo", + }); + + assert.deepEqual(feed.store.getState().DiscoveryStream.config, { + enabled: true, + api_key_pref: "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); + }); + it("shoud update allowlist", async () => { + assert.equal( + feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME], + DUMMY_ENDPOINT + ); + setPref(ENDPOINTS_PREF_NAME, "sick-kickflip.mozilla.net"); + assert.equal( + feed.store.getState().Prefs.values[ENDPOINTS_PREF_NAME], + "sick-kickflip.mozilla.net" + ); + }); + }); + + 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("#enable", () => { + it("should pass along proper options to refreshAll from enable", async () => { + sandbox.stub(feed, "refreshAll"); + await feed.enable(); + assert.calledWith(feed.refreshAll, {}); + await feed.enable({ updateOpenTabs: true }); + assert.calledWith(feed.refreshAll, { updateOpenTabs: true }); + await feed.enable({ isStartup: true }); + assert.calledWith(feed.refreshAll, { isStartup: true }); + await feed.enable({ updateOpenTabs: true, isStartup: true }); + assert.calledWith(feed.refreshAll, { + updateOpenTabs: true, + isStartup: true, + }); + }); + }); + + 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); + }); + it("should update open tabs but not startup with onPrefChange", async () => { + sandbox.stub(feed, "refreshAll"); + feed._prefCache.config = { + enabled: true, + }; + await feed.onPrefChange(); + assert.calledWith(feed.refreshAll, { updateOpenTabs: true }); + }); + }); + + 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_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 spocs on startup for content under 1 week", () => { + const spocs = { lastUpdated: Date.now() }; + const result = feed.isExpired({ + cachedData: { spocs }, + key: "spocs", + isStartup: true, + }); + + assert.isFalse(result); + }); + it("should return true for spocs for isStartup=false after 30 mins", () => { + const spocs = { lastUpdated: Date.now() }; + clock.tick(THIRTY_MINUTES + 1); + const result = feed.isExpired({ cachedData: { spocs }, key: "spocs" }); + + assert.isTrue(result); + }); + it("should return true for spocs on startup for content over 1 week", () => { + const spocs = { lastUpdated: Date.now() }; + clock.tick(ONE_WEEK + 1); + const result = feed.isExpired({ + cachedData: { spocs }, + key: "spocs", + isStartup: true, + }); + + assert.isTrue(result); + }); + }); + + describe("#checkIfAnyCacheExpired", () => { + let cache; + beforeEach(() => { + cache = { + 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 .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.spocs.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.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 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() }, + }); + clock.tick(THIRTY_MINUTES + 1); + + await feed.refreshAll({ isStartup: true }); + + // 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: [], personalized: false })); + 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", () => { + beforeEach(() => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + }); + it("should score feeds and set cache, and dispatch", async () => { + const fakeDiscoveryStream = { + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": false, + }, + }, + Personalization: { + initialized: true, + }, + DiscoveryStream: { + spocs: { + placements: [ + { name: "placement1" }, + { name: "placement2" }, + { name: "placement3" }, + ], + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + 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": { + personalized: true, + 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": { + personalized: true, + 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"], + }); + }); + + it("should skip already personalized feeds", async () => { + sandbox.spy(feed, "scoreItems"); + const recsExpireTime = 5600; + const fakeFeeds = { + data: { + "https://foo.com": { + personalized: true, + data: { + recommendations: [ + { + id: "first", + item_score: 0.7, + }, + { + id: "second", + item_score: 0.6, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + }, + }; + + await feed.scoreFeeds(fakeFeeds); + + assert.notCalled(feed.scoreItems); + }); + }); + + describe("#scoreSpocs", () => { + beforeEach(() => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + }); + it("should score spocs and set cache, dispatch", async () => { + const fakeDiscoveryStream = { + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": false, + }, + }, + Personalization: { + initialized: true, + }, + 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: { + personalized: true, + items: [ + { + score: 0.8, + item_score: 0.8, + }, + { + score: 0.6, + item_score: 0.6, + }, + { + score: 0.4, + item_score: 0.4, + }, + ], + }, + placement2: { + personalized: true, + 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 + ); + }); + + it("should skip already personalized spocs", async () => { + sandbox.spy(feed, "scoreItems"); + const fakeDiscoveryStream = { + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": false, + }, + }, + Personalization: { + initialized: true, + }, + DiscoveryStream: { + spocs: { + placements: [{ name: "placement1" }], + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + const fakeSpocs = { + lastUpdated: 1234, + data: { + placement1: { + personalized: true, + items: [ + { + item_score: 0.6, + }, + { + item_score: 0.4, + }, + { + item_score: 0.8, + }, + ], + }, + }, + }; + + await feed.scoreSpocs(fakeSpocs); + + assert.notCalled(feed.scoreItems); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_UPDATED", () => { + it("should call scoreFeeds and scoreSpocs if loaded", async () => { + const fakeDiscoveryStream = { + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + }, + }, + DiscoveryStream: { + feeds: { loaded: false }, + spocs: { loaded: false }, + }, + }; + + sandbox.stub(feed, "scoreFeeds").resolves(); + sandbox.stub(feed, "scoreSpocs").resolves(); + Object.defineProperty(feed, "personalized", { get: () => true }); + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + }); + + assert.notCalled(feed.scoreFeeds); + assert.notCalled(feed.scoreSpocs); + + fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true; + fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true; + + await feed.onAction({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED, + }); + + assert.calledOnce(feed.scoreFeeds); + assert.calledOnce(feed.scoreSpocs); + }); + }); + + describe("#observe", () => { + 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("#scoreItem", () => { + it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => { + const item = { + item_score: 0.6, + }; + feed.recommendationProvider.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®ion=$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: [], personalized: false })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + data: [ + { + recommendationId: "decaf-c0ff33", + tileId: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + timeToRead: "timeToRead", + imageUrl: "imageUrl", + }, + ], + }); + + const feedData = await feed.getComponentFeed("url"); + assert.deepEqual(feedData, { + lastUpdated: 0, + personalized: false, + data: { + settings: {}, + recommendations: [ + { + id: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + time_to_read: "timeToRead", + raw_image_src: "imageUrl", + recommendation_id: "decaf-c0ff33", + }, + ], + 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..ac262baf90 --- /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.sys.mjs"; +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..e9be9b86ba --- /dev/null +++ b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js @@ -0,0 +1,233 @@ +"use strict"; +import { FaviconFeed, fetchIconFromRedirects } from "lib/FaviconFeed.sys.mjs"; +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..0e98a0d006 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/FilterAdult.test.js @@ -0,0 +1,112 @@ +import { FilterAdult } from "lib/FilterAdult.sys.mjs"; +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/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/NewTabInit.test.js b/browser/components/newtab/test/unit/lib/NewTabInit.test.js new file mode 100644 index 0000000000..68ab9d7821 --- /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.sys.mjs"; + +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..18c634d43d --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js @@ -0,0 +1,95 @@ +import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.mjs"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.mjs"; + +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..aae070b305 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js @@ -0,0 +1,479 @@ +import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.mjs"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.mjs"; + +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..0058fd7c76 --- /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.sys.mjs"; + +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..da6454c6d6 --- /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.mjs"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.mjs"; +import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.mjs"; +import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.mjs"; +import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.mjs"; + +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..fdbcae9613 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js @@ -0,0 +1,1543 @@ +import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.mjs"; +import { tokenize } from "lib/PersonalityProvider/Tokenize.mjs"; + +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..19e738d451 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js @@ -0,0 +1,134 @@ +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.mjs"; + +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/PrefsFeed.test.js b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js new file mode 100644 index 0000000000..498c7198ab --- /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.sys.mjs"; + +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..9e68f4869a --- /dev/null +++ b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js @@ -0,0 +1,331 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs"; +import { combineReducers, createStore } from "redux"; +import { reducers } from "common/Reducers.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.sys.mjs"; +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; + +const PREF_PERSONALIZATION_ENABLED = "discoverystream.personalization.enabled"; +const PREF_PERSONALIZATION_MODEL_KEYS = + "discoverystream.personalization.modelKeys"; +describe("RecommendationProvider", () => { + let feed; + let sandbox; + let clock; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set({ + PersistentCache, + PersonalityProvider, + }); + + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + feed = new RecommendationProvider(); + feed.store = createStore(combineReducers(reducers), {}); + }); + + afterEach(() => { + sandbox.restore(); + clock.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("#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 ", () => { + sandbox.stub(global.Services.obs, "removeObserver").returns(); + feed.loaded = true; + feed.provider = { + teardown: sandbox.stub().resolves(), + }; + feed.teardown(); + assert.calledOnce(feed.provider.teardown); + assert.calledOnce(global.Services.obs.removeObserver); + assert.calledWith(global.Services.obs.removeObserver, feed, "idle-daily"); + }); + }); + + 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("#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("#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: INIT", () => { + it("should ", async () => { + sandbox.stub(feed, "enable").returns(); + await feed.onAction({ + type: at.INIT, + }); + assert.calledOnce(feed.enable); + assert.calledWith(feed.enable, true); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE", () => { + it("should ", async () => { + sandbox.stub(feed, "personalizationOverride").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE, + data: { override: true }, + }); + assert.calledOnce(feed.personalizationOverride); + assert.calledWith(feed.personalizationOverride, true); + }); + }); + + describe("#loadPersonalizationScoresCache", () => { + it("should create a personalization provider from cached scores", async () => { + sandbox.spy(feed.store, "dispatch"); + sandbox.spy(feed.cache, "set"); + feed.provider = { + init: async () => {}, + getScores: () => "scores", + }; + 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("#updatePersonalizationScores", () => { + beforeEach(() => { + sandbox.spy(feed.store, "dispatch"); + sandbox.spy(feed.cache, "set"); + sandbox.spy(feed, "setProvider"); + feed.provider = { + init: async () => {}, + getScores: () => "scores", + }; + }); + it("should update provider 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, + }, + }, + }); + + await feed.updatePersonalizationScores(); + + assert.calledWith( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: 0, + }, + }) + ); + assert.calledWith(feed.cache.set, "personalization", { + scores: "scores", + _timestamp: 0, + }); + }); + it("should not update provider on updatePersonalizationScores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "discoverystream.personalization.enabled": false, + }, + }, + }); + await feed.updatePersonalizationScores(); + + assert.notCalled(feed.setProvider); + }); + }); + + 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) + ); + }); + }); + + describe("#observe", () => { + it("should call updatePersonalizationScores on idle daily", async () => { + sandbox.stub(feed, "updatePersonalizationScores").returns(); + feed.observe(null, "idle-daily"); + assert.calledOnce(feed.updatePersonalizationScores); + }); + }); +}); 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..a03f06890d --- /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.sys.mjs"; + +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..b3a9abd70c --- /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.sys.mjs"; + +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..201e5226fd --- /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.sys.mjs"; + +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..cd97707e9b --- /dev/null +++ b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js @@ -0,0 +1,252 @@ +import { classifySite } from "lib/SiteClassifier.sys.mjs"; + +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¶m2=val2", + RemoteSettings + ) + ); + assert.equal( + "params-match", + await classifySite( + "https://example.com?param1=val1¶m2=val2&other=other", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite( + "https://example.com?param1=val2¶m2=val1", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite("https://example.com?param1¶m2", 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/SystemTickFeed.test.js b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js new file mode 100644 index 0000000000..a0789b182e --- /dev/null +++ b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js @@ -0,0 +1,79 @@ +import { + SYSTEM_TICK_INTERVAL, + SystemTickFeed, +} from "lib/SystemTickFeed.sys.mjs"; +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/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/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 + ); + }); + }); +}); |