diff options
Diffstat (limited to 'browser/components/newtab/test/unit/content-src')
48 files changed, 10178 insertions, 0 deletions
diff --git a/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx b/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx new file mode 100644 index 0000000000..1bd01fb220 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx @@ -0,0 +1,516 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + ASRouterAdminInner, + CollapseToggle, + DiscoveryStreamAdmin, + Personalization, + ToggleStoryButton, +} from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUtils } from "content-src/asrouter/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("ASRouterAdmin", () => { + let globalOverrider; + let sandbox; + let wrapper; + let globals; + let FAKE_PROVIDER_PREF = [ + { + enabled: true, + id: "snippets_local_testing", + localProvider: "SnippetsProvider", + type: "local", + }, + ]; + let FAKE_PROVIDER = [ + { + enabled: true, + id: "snippets_local_testing", + localProvider: "SnippetsProvider", + messages: [], + type: "local", + }, + ]; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns("foo"); + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + }; + globalOverrider.set(globals); + wrapper = shallow( + <ASRouterAdminInner collapsed={false} location={{ routes: [""] }} /> + ); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should render ASRouterAdmin component", () => { + assert.ok(wrapper.exists()); + }); + it("should send ADMIN_CONNECT_STATE on mount", () => { + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "ADMIN_CONNECT_STATE", + data: { endpoint: "foo" }, + }); + }); + it("should set a .collapsed class on the outer div if props.collapsed is true", () => { + wrapper.setProps({ collapsed: true }); + assert.isTrue(wrapper.find(".asrouter-admin").hasClass("collapsed")); + }); + it("should set a .expanded class on the outer div if props.collapsed is false", () => { + wrapper.setProps({ collapsed: false }); + assert.isTrue(wrapper.find(".asrouter-admin").hasClass("expanded")); + assert.isFalse(wrapper.find(".asrouter-admin").hasClass("collapsed")); + }); + describe("#getSection", () => { + it("should render a message provider section by default", () => { + assert.equal(wrapper.find("h2").at(1).text(), "Messages"); + }); + it("should render a targeting section for targeting route", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["targeting"] }} /> + ); + assert.equal(wrapper.find("h2").at(0).text(), "Targeting Utilities"); + }); + it("should render a DS section for DS route", () => { + wrapper = shallow( + <ASRouterAdminInner + location={{ routes: ["ds"] }} + Sections={[]} + Prefs={{}} + /> + ); + assert.equal(wrapper.find("h2").at(0).text(), "Discovery Stream"); + }); + it("should render two error messages", () => { + wrapper = shallow( + <ASRouterAdminInner location={{ routes: ["errors"] }} Sections={[]} /> + ); + const firstError = { + timestamp: Date.now() + 100, + error: { message: "first" }, + }; + const secondError = { + timestamp: Date.now(), + error: { message: "second" }, + }; + wrapper.setState({ + providers: [{ id: "foo", errors: [firstError, secondError] }], + }); + + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(0).text(), + "foo" + ); + assert.lengthOf(wrapper.find("tbody tr"), 2); + assert.equal( + wrapper.find("tbody tr").at(0).find("td").at(1).text(), + secondError.error.message + ); + }); + }); + describe("#render", () => { + beforeEach(() => { + wrapper.setState({ + providerPrefs: [], + providers: [], + userPrefs: {}, + }); + }); + describe("#renderProviders", () => { + it("should render the provider", () => { + wrapper.setState({ + providerPrefs: FAKE_PROVIDER_PREF, + providers: FAKE_PROVIDER, + }); + + // Header + 1 item + assert.lengthOf(wrapper.find(".message-item"), 2); + }); + }); + describe("#renderMessages", () => { + beforeEach(() => { + sandbox.stub(ASRouterUtils, "blockById").resolves(); + sandbox.stub(ASRouterUtils, "unblockById").resolves(); + sandbox.stub(ASRouterUtils, "overrideMessage").resolves({ foo: "bar" }); + sandbox.stub(ASRouterUtils, "sendMessage").resolves(); + wrapper.setState({ + messageFilter: "all", + messageBlockList: [], + messageImpressions: { foo: 2 }, + groups: [{ id: "messageProvider", enabled: true }], + providers: [{ id: "messageProvider", enabled: true }], + }); + }); + it("should render a message when no filtering is applied", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.primary").simulate("click"); + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, "foo"); + }); + it("should render a blocked message", () => { + wrapper.setState({ + messages: [ + { + id: "foo", + groups: ["messageProvider"], + provider: "messageProvider", + }, + ], + messageBlockList: ["foo"], + }); + assert.lengthOf(wrapper.find(".message-item.blocked"), 1); + wrapper.find(".message-item.blocked button").simulate("click"); + assert.calledOnce(ASRouterUtils.unblockById); + assert.calledWith(ASRouterUtils.unblockById, "foo"); + }); + it("should render a message if provider matches filter", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + }); + it("should override with the selected message", async () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + wrapper.find(".message-item button.show").simulate("click"); + assert.calledOnce(ASRouterUtils.overrideMessage); + assert.calledWith(ASRouterUtils.overrideMessage, "foo"); + await ASRouterUtils.overrideMessage(); + assert.equal(wrapper.state().foo, "bar"); + }); + it("should hide message if provider filter changes", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".message-id"), 1); + + wrapper.find("select").simulate("change", { target: { value: "bar" } }); + + assert.lengthOf(wrapper.find(".message-id"), 0); + }); + it("should not display Reset All button if provider filter value is set to all or test providers", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + }); + + assert.lengthOf(wrapper.find(".messages-reset"), 1); + wrapper.find("select").simulate("change", { target: { value: "all" } }); + + assert.lengthOf(wrapper.find(".messages-reset"), 0); + + wrapper + .find("select") + .simulate("change", { target: { value: "test_local_testing" } }); + assert.lengthOf(wrapper.find(".messages-reset"), 0); + }); + it("should trigger disable and enable provider on Reset All button click", () => { + wrapper.setState({ + messageFilter: "messageProvider", + messages: [ + { + id: "foo", + provider: "messageProvider", + groups: ["messageProvider"], + }, + ], + providerPrefs: [ + { + id: "messageProvider", + }, + ], + }); + wrapper.find(".messages-reset").simulate("click"); + assert.calledTwice(ASRouterUtils.sendMessage); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "DISABLE_PROVIDER", + data: "messageProvider", + }); + assert.calledWith(ASRouterUtils.sendMessage, { + type: "ENABLE_PROVIDER", + data: "messageProvider", + }); + }); + }); + }); + describe("#DiscoveryStream", () => { + let state = {}; + let dispatch; + beforeEach(() => { + dispatch = sandbox.stub(); + state = { + config: { + enabled: true, + layout_endpoint: "", + }, + layout: [], + spocs: { + frequency_caps: [], + }, + feeds: { + data: {}, + }, + }; + wrapper = shallow( + <DiscoveryStreamAdmin + dispatch={dispatch} + otherPrefs={{}} + state={{ + DiscoveryStream: state, + }} + /> + ); + }); + it("should render a DiscoveryStreamAdmin component", () => { + assert.equal(wrapper.find("h3").at(0).text(), "Endpoint variant"); + }); + it("should render a spoc in DiscoveryStreamAdmin component", () => { + state.spocs = { + frequency_caps: [], + data: { + spocs: { + items: [ + { + id: 12345, + }, + ], + }, + }, + }; + wrapper = shallow( + <DiscoveryStreamAdmin + otherPrefs={{}} + state={{ DiscoveryStream: state }} + /> + ); + wrapper.instance().onStoryToggle({ id: 12345 }); + const messageSummary = wrapper.find(".message-summary").at(0); + const pre = messageSummary.find("pre").at(0); + const spocText = pre.text(); + assert.equal(spocText, '{\n "id": 12345\n}'); + }); + it("should fire restorePrefDefaults with DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", () => { + wrapper.find("button").at(0).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }) + ); + }); + it("should fire config change with DISCOVERY_STREAM_CONFIG_CHANGE", () => { + wrapper.find("button").at(1).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: { enabled: true, layout_endpoint: "" }, + }) + ); + }); + it("should fire expireCache with DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => { + wrapper.find("button").at(2).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE, + }) + ); + }); + it("should fire systemTick with DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => { + wrapper.find("button").at(3).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK, + }) + ); + }); + it("should fire idleDaily with DISCOVERY_STREAM_DEV_IDLE_DAILY", () => { + wrapper.find("button").at(4).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY, + }) + ); + }); + it("should fire syncRemoteSettings with DISCOVERY_STREAM_DEV_SYNC_RS", () => { + wrapper.find("button").at(5).simulate("click"); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_DEV_SYNC_RS, + }) + ); + }); + it("should fire setConfigValue with DISCOVERY_STREAM_CONFIG_SET_VALUE", () => { + const name = "name"; + const value = "value"; + wrapper.instance().setConfigValue(name, value); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name, value }, + }) + ); + }); + }); + + describe("#Personalization", () => { + let dispatch; + beforeEach(() => { + dispatch = sandbox.stub(); + wrapper = shallow( + <Personalization + dispatch={dispatch} + state={{ + Personalization: { + lastUpdated: 1000, + initialized: true, + }, + }} + /> + ); + }); + it("should render with pref checkbox, lastUpdated, and initialized", () => { + assert.lengthOf(wrapper.find("TogglePrefCheckbox"), 1); + assert.equal( + wrapper.find("td").at(1).text(), + "Personalization Last Updated" + ); + assert.equal( + wrapper.find("td").at(2).text(), + new Date(1000).toLocaleString() + ); + assert.equal( + wrapper.find("td").at(3).text(), + "Personalization Initialized" + ); + assert.equal(wrapper.find("td").at(4).text(), "true"); + }); + it("should render with no data with no last updated", () => { + wrapper = shallow( + <Personalization + dispatch={dispatch} + state={{ + Personalization: { + version: 2, + lastUpdated: 0, + initialized: true, + }, + }} + /> + ); + assert.equal(wrapper.find("td").at(2).text(), "(no data)"); + }); + it("should dispatch DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", () => { + wrapper.instance().togglePersonalization(); + assert.calledWith( + dispatch, + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }) + ); + }); + }); + + describe("#ToggleStoryButton", () => { + it("should fire onClick in toggle button", async () => { + let result = ""; + function onClick(spoc) { + result = spoc; + } + + wrapper = shallow(<ToggleStoryButton story="spoc" onClick={onClick} />); + wrapper.find("button").simulate("click"); + + assert.equal(result, "spoc"); + }); + }); +}); + +describe("CollapseToggle", () => { + let wrapper; + beforeEach(() => { + wrapper = shallow(<CollapseToggle location={{ routes: [""] }} />); + }); + + describe("rendering inner content", () => { + it("should not render ASRouterAdminInner for about:newtab (no hash)", () => { + wrapper.setProps({ location: { hash: "", routes: [""] } }); + assert.lengthOf(wrapper.find(ASRouterAdminInner), 0); + }); + + it("should render ASRouterAdminInner for about:newtab#asrouter and subroutes", () => { + wrapper.setProps({ location: { hash: "#asrouter", routes: [""] } }); + assert.lengthOf(wrapper.find(ASRouterAdminInner), 1); + + wrapper.setProps({ location: { hash: "#asrouter-foo", routes: [""] } }); + assert.lengthOf(wrapper.find(ASRouterAdminInner), 1); + }); + + it("should render ASRouterAdminInner for about:newtab#devtools and subroutes", () => { + wrapper.setProps({ location: { hash: "#devtools", routes: [""] } }); + assert.lengthOf(wrapper.find(ASRouterAdminInner), 1); + + wrapper.setProps({ location: { hash: "#devtools-foo", routes: [""] } }); + assert.lengthOf(wrapper.find(ASRouterAdminInner), 1); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/Base.test.jsx b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx new file mode 100644 index 0000000000..3dd7a3d536 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/Base.test.jsx @@ -0,0 +1,130 @@ +import { + _Base as Base, + BaseContent, + PrefsButton, +} from "content-src/components/Base/Base"; +import { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import React from "react"; +import { Search } from "content-src/components/Search/Search"; +import { shallow } from "enzyme"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +describe("<Base>", () => { + let DEFAULT_PROPS = { + store: { getState: () => {} }, + App: { initialized: true }, + Prefs: { values: {} }, + Sections: [], + DiscoveryStream: { config: { enabled: false } }, + dispatch: () => {}, + adminContent: { + message: {}, + }, + }; + + it("should render Base component", () => { + const wrapper = shallow(<Base {...DEFAULT_PROPS} />); + assert.ok(wrapper.exists()); + }); + + it("should render the BaseContent component, passing through all props", () => { + const wrapper = shallow(<Base {...DEFAULT_PROPS} />); + const props = wrapper.find(BaseContent).props(); + assert.deepEqual( + props, + DEFAULT_PROPS, + JSON.stringify([props, DEFAULT_PROPS], null, 3) + ); + }); + + it("should render an ErrorBoundary with class base-content-fallback", () => { + const wrapper = shallow(<Base {...DEFAULT_PROPS} />); + + assert.equal( + wrapper.find(ErrorBoundary).first().prop("className"), + "base-content-fallback" + ); + }); + + it("should render an ASRouterAdmin if the devtools pref is true", () => { + const wrapper = shallow( + <Base + {...DEFAULT_PROPS} + Prefs={{ values: { "asrouter.devtoolsEnabled": true } }} + /> + ); + assert.lengthOf(wrapper.find(ASRouterAdmin), 1); + }); + + it("should not render an ASRouterAdmin if the devtools pref is false", () => { + const wrapper = shallow( + <Base + {...DEFAULT_PROPS} + Prefs={{ values: { "asrouter.devtoolsEnabled": false } }} + /> + ); + assert.lengthOf(wrapper.find(ASRouterAdmin), 0); + }); +}); + +describe("<BaseContent>", () => { + let DEFAULT_PROPS = { + store: { getState: () => {} }, + App: { initialized: true }, + Prefs: { values: {} }, + Sections: [], + DiscoveryStream: { config: { enabled: false } }, + dispatch: () => {}, + }; + + it("should render an ErrorBoundary with a Search child", () => { + const searchEnabledProps = Object.assign({}, DEFAULT_PROPS, { + Prefs: { values: { showSearch: true } }, + }); + + const wrapper = shallow(<BaseContent {...searchEnabledProps} />); + + assert.isTrue(wrapper.find(Search).parent().is(ErrorBoundary)); + }); + + it("should dispatch a user event when the customize menu is opened or closed", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <BaseContent + {...DEFAULT_PROPS} + dispatch={dispatch} + App={{ customizeMenuVisible: true }} + /> + ); + wrapper.instance().openCustomizationMenu(); + assert.calledWith(dispatch, { type: "SHOW_PERSONALIZE" }); + assert.calledWith(dispatch, ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + wrapper.instance().closeCustomizationMenu(); + assert.calledWith(dispatch, { type: "HIDE_PERSONALIZE" }); + assert.calledWith(dispatch, ac.UserEvent({ event: "HIDE_PERSONALIZE" })); + }); + + it("should render only search if no Sections are enabled", () => { + const onlySearchProps = Object.assign({}, DEFAULT_PROPS, { + Sections: [{ id: "highlights", enabled: false }], + Prefs: { values: { showSearch: true } }, + }); + + const wrapper = shallow(<BaseContent {...onlySearchProps} />); + assert.lengthOf(wrapper.find(".only-search"), 1); + }); +}); + +describe("<PrefsButton>", () => { + it("should render icon-settings if props.icon is empty", () => { + const wrapper = shallow(<PrefsButton icon="" />); + + assert.isTrue(wrapper.find("button").hasClass("icon-settings")); + }); + it("should render props.icon as a className", () => { + const wrapper = shallow(<PrefsButton icon="icon-happy" />); + + assert.isTrue(wrapper.find("button").hasClass("icon-happy")); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/Card.test.jsx b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx new file mode 100644 index 0000000000..5f07570b2e --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/Card.test.jsx @@ -0,0 +1,510 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + _Card as Card, + PlaceholderCard, +} from "content-src/components/Card/Card"; +import { combineReducers, createStore } from "redux"; +import { GlobalOverrider } from "test/unit/utils"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { cardContextTypes } from "content-src/components/Card/types"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { Provider } from "react-redux"; +import React from "react"; +import { shallow, mount } from "enzyme"; + +let DEFAULT_PROPS = { + dispatch: sinon.stub(), + index: 0, + link: { + hostname: "foo", + title: "A title for foo", + url: "http://www.foo.com", + type: "history", + description: "A description for foo", + image: "http://www.foo.com/img.png", + guid: 1, + }, + eventSource: "TOP_STORIES", + shouldSendImpressionStats: true, + contextMenuOptions: ["Separator"], +}; + +let DEFAULT_BLOB_IMAGE = { + path: "/testpath", + data: new Blob([0]), +}; + +function mountCardWithProps(props) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <Card {...props} /> + </Provider> + ); +} + +describe("<Card>", () => { + let globals; + let wrapper; + beforeEach(() => { + globals = new GlobalOverrider(); + wrapper = mountCardWithProps(DEFAULT_PROPS); + }); + afterEach(() => { + DEFAULT_PROPS.dispatch.reset(); + globals.restore(); + }); + it("should render a Card component", () => assert.ok(wrapper.exists())); + it("should add the right url", () => { + assert.propertyVal( + wrapper.find("a").props(), + "href", + DEFAULT_PROPS.link.url + ); + + // test that pocket cards get a special open_url href + const pocketLink = Object.assign({}, DEFAULT_PROPS.link, { + open_url: "getpocket.com/foo", + type: "pocket", + }); + wrapper = mount( + <Card {...Object.assign({}, DEFAULT_PROPS, { link: pocketLink })} /> + ); + assert.propertyVal(wrapper.find("a").props(), "href", pocketLink.open_url); + }); + it("should display a title", () => + assert.equal(wrapper.find(".card-title").text(), DEFAULT_PROPS.link.title)); + it("should display a description", () => + assert.equal( + wrapper.find(".card-description").text(), + DEFAULT_PROPS.link.description + )); + it("should display a host name", () => + assert.equal(wrapper.find(".card-host-name").text(), "foo")); + it("should have a link menu button", () => + assert.ok(wrapper.find(".context-menu-button").exists())); + it("should render a link menu when button is clicked", () => { + const button = wrapper.find(".context-menu-button"); + assert.equal(wrapper.find(LinkMenu).length, 0); + button.simulate("click", { preventDefault: () => {} }); + assert.equal(wrapper.find(LinkMenu).length, 1); + }); + it("should pass dispatch, source, onUpdate, site, options, and index to LinkMenu", () => { + wrapper + .find(".context-menu-button") + .simulate("click", { preventDefault: () => {} }); + const { dispatch, source, onUpdate, site, options, index } = wrapper + .find(LinkMenu) + .props(); + assert.equal(dispatch, DEFAULT_PROPS.dispatch); + assert.equal(source, DEFAULT_PROPS.eventSource); + assert.ok(onUpdate); + assert.equal(site, DEFAULT_PROPS.link); + assert.equal(options, DEFAULT_PROPS.contextMenuOptions); + assert.equal(index, DEFAULT_PROPS.index); + }); + it("should pass through the correct menu options to LinkMenu if overridden by individual card", () => { + const link = Object.assign({}, DEFAULT_PROPS.link); + link.contextMenuOptions = ["CheckBookmark"]; + + wrapper = mountCardWithProps(Object.assign({}, DEFAULT_PROPS, { link })); + wrapper + .find(".context-menu-button") + .simulate("click", { preventDefault: () => {} }); + const { options } = wrapper.find(LinkMenu).props(); + assert.equal(options, link.contextMenuOptions); + }); + it("should have a context based on type", () => { + wrapper = shallow(<Card {...DEFAULT_PROPS} />); + const context = wrapper.find(".card-context"); + const { icon, fluentID } = cardContextTypes[DEFAULT_PROPS.link.type]; + assert.isTrue(context.childAt(0).hasClass(`icon-${icon}`)); + assert.isTrue(context.childAt(1).hasClass("card-context-label")); + assert.equal(context.childAt(1).prop("data-l10n-id"), fluentID); + }); + it("should support setting custom context", () => { + const linkWithCustomContext = { + type: "history", + context: "Custom", + icon: "icon-url", + }; + + wrapper = shallow( + <Card + {...Object.assign({}, DEFAULT_PROPS, { link: linkWithCustomContext })} + /> + ); + const context = wrapper.find(".card-context"); + const { icon } = cardContextTypes[DEFAULT_PROPS.link.type]; + assert.isFalse(context.childAt(0).hasClass(`icon-${icon}`)); + assert.equal( + context.childAt(0).props().style.backgroundImage, + "url('icon-url')" + ); + + assert.isTrue(context.childAt(1).hasClass("card-context-label")); + assert.equal(context.childAt(1).text(), linkWithCustomContext.context); + }); + it("should parse args for fluent correctly", () => { + const title = '"fluent"'; + const link = { ...DEFAULT_PROPS.link, title }; + + wrapper = mountCardWithProps({ ...DEFAULT_PROPS, link }); + let button = wrapper.find(ContextMenuButton).find("button"); + + assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title })); + }); + it("should have .active class, on card-outer if context menu is open", () => { + const button = wrapper.find(ContextMenuButton); + assert.isFalse( + wrapper.find(".card-outer").hasClass("active"), + "does not have active class" + ); + button.simulate("click", { preventDefault: () => {} }); + assert.isTrue( + wrapper.find(".card-outer").hasClass("active"), + "has active class" + ); + }); + it("should send OPEN_DOWNLOAD_FILE if we clicked on a download", () => { + const downloadLink = { + type: "download", + url: "download.mov", + }; + wrapper = mountCardWithProps( + Object.assign({}, DEFAULT_PROPS, { link: downloadLink }) + ); + const card = wrapper.find(".card"); + card.simulate("click", { preventDefault: () => {} }); + assert.calledThrice(DEFAULT_PROPS.dispatch); + + assert.equal( + DEFAULT_PROPS.dispatch.firstCall.args[0].type, + at.OPEN_DOWNLOAD_FILE + ); + assert.deepEqual( + DEFAULT_PROPS.dispatch.firstCall.args[0].data, + downloadLink + ); + }); + it("should send OPEN_LINK if we clicked on anything other than a download", () => { + const nonDownloadLink = { + type: "history", + url: "download.mov", + }; + wrapper = mountCardWithProps( + Object.assign({}, DEFAULT_PROPS, { link: nonDownloadLink }) + ); + const card = wrapper.find(".card"); + const event = { + altKey: "1", + button: "2", + ctrlKey: "3", + metaKey: "4", + shiftKey: "5", + }; + card.simulate( + "click", + Object.assign({}, event, { preventDefault: () => {} }) + ); + assert.calledThrice(DEFAULT_PROPS.dispatch); + + assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK); + }); + describe("card image display", () => { + const DEFAULT_BLOB_URL = "blob://test"; + let url; + beforeEach(() => { + url = { + createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL), + revokeObjectURL: globals.sandbox.spy(), + }; + globals.set("URL", url); + }); + afterEach(() => { + globals.restore(); + }); + it("should display a regular image correctly and not call revokeObjectURL when unmounted", () => { + wrapper = shallow(<Card {...DEFAULT_PROPS} />); + + assert.isUndefined(wrapper.state("cardImage").path); + assert.equal(wrapper.state("cardImage").url, DEFAULT_PROPS.link.image); + assert.equal( + wrapper.find(".card-preview-image").props().style.backgroundImage, + `url(${wrapper.state("cardImage").url})` + ); + + wrapper.unmount(); + assert.notCalled(url.revokeObjectURL); + }); + it("should display a blob image correctly and revoke blob url when unmounted", () => { + const link = Object.assign({}, DEFAULT_PROPS.link, { + image: DEFAULT_BLOB_IMAGE, + }); + wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />); + + assert.equal(wrapper.state("cardImage").path, DEFAULT_BLOB_IMAGE.path); + assert.equal(wrapper.state("cardImage").url, DEFAULT_BLOB_URL); + assert.equal( + wrapper.find(".card-preview-image").props().style.backgroundImage, + `url(${wrapper.state("cardImage").url})` + ); + + wrapper.unmount(); + assert.calledOnce(url.revokeObjectURL); + }); + it("should not show an image if there isn't one and not call revokeObjectURL when unmounted", () => { + const link = Object.assign({}, DEFAULT_PROPS.link); + delete link.image; + + wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />); + + assert.isNull(wrapper.state("cardImage")); + assert.lengthOf(wrapper.find(".card-preview-image"), 0); + + wrapper.unmount(); + assert.notCalled(url.revokeObjectURL); + }); + it("should remove current card image if new image is not present", () => { + wrapper = shallow(<Card {...DEFAULT_PROPS} />); + + const otherLink = Object.assign({}, DEFAULT_PROPS.link); + delete otherLink.image; + wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink })); + + assert.isNull(wrapper.state("cardImage")); + }); + it("should not create or revoke urls if normal image is already in state", () => { + wrapper = shallow(<Card {...DEFAULT_PROPS} />); + + wrapper.setProps(DEFAULT_PROPS); + + assert.notCalled(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + }); + it("should not create or revoke more urls if blob image is already in state", () => { + const link = Object.assign({}, DEFAULT_PROPS.link, { + image: DEFAULT_BLOB_IMAGE, + }); + wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />); + + assert.calledOnce(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + + wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link })); + + assert.calledOnce(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + }); + it("should create blob urls for new blobs and revoke existing ones", () => { + const link = Object.assign({}, DEFAULT_PROPS.link, { + image: DEFAULT_BLOB_IMAGE, + }); + wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />); + + assert.calledOnce(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + + const otherLink = Object.assign({}, DEFAULT_PROPS.link, { + image: { path: "/newpath", data: new Blob([0]) }, + }); + wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink })); + + assert.calledTwice(url.createObjectURL); + assert.calledOnce(url.revokeObjectURL); + }); + it("should not call createObjectURL and revokeObjectURL for normal images", () => { + wrapper = shallow(<Card {...DEFAULT_PROPS} />); + + assert.notCalled(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + + const otherLink = Object.assign({}, DEFAULT_PROPS.link, { + image: "https://other/image", + }); + wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink })); + + assert.notCalled(url.createObjectURL); + assert.notCalled(url.revokeObjectURL); + }); + }); + describe("image loading", () => { + let link; + let triggerImage = {}; + let uniqueLink = 0; + beforeEach(() => { + global.Image.prototype = { + addEventListener(event, callback) { + triggerImage[event] = () => Promise.resolve(callback()); + }, + }; + + link = Object.assign({}, DEFAULT_PROPS.link); + link.image += uniqueLink++; + wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />); + }); + it("should have a loaded preview image when the image is loaded", () => { + assert.isFalse(wrapper.find(".card-preview-image").hasClass("loaded")); + + wrapper.setState({ imageLoaded: true }); + + assert.isTrue(wrapper.find(".card-preview-image").hasClass("loaded")); + }); + it("should start not loaded", () => { + assert.isFalse(wrapper.state("imageLoaded")); + }); + it("should be loaded after load", async () => { + await triggerImage.load(); + + assert.isTrue(wrapper.state("imageLoaded")); + }); + it("should be not be loaded after error ", async () => { + await triggerImage.error(); + + assert.isFalse(wrapper.state("imageLoaded")); + }); + it("should be not be loaded if image changes", async () => { + await triggerImage.load(); + const otherLink = Object.assign({}, link, { + image: "https://other/image", + }); + + wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink })); + + assert.isFalse(wrapper.state("imageLoaded")); + }); + }); + describe("placeholder=true", () => { + beforeEach(() => { + wrapper = mount(<Card placeholder={true} />); + }); + it("should render when placeholder=true", () => { + assert.ok(wrapper.exists()); + }); + it("should add a placeholder class to the outer element", () => { + assert.isTrue(wrapper.find(".card-outer").hasClass("placeholder")); + }); + it("should not have a context menu button or LinkMenu", () => { + assert.isFalse( + wrapper.find(ContextMenuButton).exists(), + "context menu button" + ); + assert.isFalse(wrapper.find(LinkMenu).exists(), "LinkMenu"); + }); + it("should not call onLinkClick when the link is clicked", () => { + const spy = sinon.spy(wrapper.instance(), "onLinkClick"); + const card = wrapper.find(".card"); + card.simulate("click"); + assert.notCalled(spy); + }); + }); + describe("#trackClick", () => { + it("should call dispatch when the link is clicked with the right data", () => { + const card = wrapper.find(".card"); + const event = { + altKey: "1", + button: "2", + ctrlKey: "3", + metaKey: "4", + shiftKey: "5", + }; + card.simulate( + "click", + Object.assign({}, event, { preventDefault: () => {} }) + ); + assert.calledThrice(DEFAULT_PROPS.dispatch); + + // first dispatch call is the AlsoToMain message which will open a link in a window, and send some event data + assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK); + assert.deepEqual( + DEFAULT_PROPS.dispatch.firstCall.args[0].data.event, + event + ); + + // second dispatch call is a UserEvent action for telemetry + assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]); + assert.calledWith( + DEFAULT_PROPS.dispatch.secondCall, + ac.UserEvent({ + event: "CLICK", + source: DEFAULT_PROPS.eventSource, + action_position: DEFAULT_PROPS.index, + }) + ); + + // third dispatch call is to send impression stats + assert.calledWith( + DEFAULT_PROPS.dispatch.thirdCall, + ac.ImpressionStats({ + source: DEFAULT_PROPS.eventSource, + click: 0, + tiles: [{ id: DEFAULT_PROPS.link.guid, pos: DEFAULT_PROPS.index }], + }) + ); + }); + it("should provide card_type to telemetry info if type is not history", () => { + const link = Object.assign({}, DEFAULT_PROPS.link); + link.type = "bookmark"; + wrapper = mount(<Card {...Object.assign({}, DEFAULT_PROPS, { link })} />); + const card = wrapper.find(".card"); + const event = { + altKey: "1", + button: "2", + ctrlKey: "3", + metaKey: "4", + shiftKey: "5", + }; + + card.simulate( + "click", + Object.assign({}, event, { preventDefault: () => {} }) + ); + + assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]); + assert.calledWith( + DEFAULT_PROPS.dispatch.secondCall, + ac.UserEvent({ + event: "CLICK", + source: DEFAULT_PROPS.eventSource, + action_position: DEFAULT_PROPS.index, + value: { card_type: link.type }, + }) + ); + }); + it("should notify Web Extensions with WEBEXT_CLICK if props.isWebExtension is true", () => { + wrapper = mountCardWithProps( + Object.assign({}, DEFAULT_PROPS, { + isWebExtension: true, + eventSource: "MyExtension", + index: 3, + }) + ); + const card = wrapper.find(".card"); + const event = { preventDefault() {} }; + card.simulate("click", event); + assert.calledWith( + DEFAULT_PROPS.dispatch, + ac.WebExtEvent(at.WEBEXT_CLICK, { + source: "MyExtension", + url: DEFAULT_PROPS.link.url, + action_position: 3, + }) + ); + }); + }); +}); + +describe("<PlaceholderCard />", () => { + it("should render a Card with placeholder=true", () => { + const wrapper = mount( + <Provider store={createStore(combineReducers(reducers), INITIAL_STATE)}> + <PlaceholderCard /> + </Provider> + ); + assert.isTrue(wrapper.find(Card).props().placeholder); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx b/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx new file mode 100644 index 0000000000..f2a8e276b4 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/CollapsibleSection.test.jsx @@ -0,0 +1,67 @@ +import { _CollapsibleSection as CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { mount } from "enzyme"; +import React from "react"; + +const DEFAULT_PROPS = { + id: "cool", + className: "cool-section", + title: "Cool Section", + prefName: "collapseSection", + collapsed: false, + eventSource: "foo", + document: { + addEventListener: () => {}, + removeEventListener: () => {}, + visibilityState: "visible", + }, + dispatch: () => {}, + Prefs: { values: { featureConfig: {} } }, +}; + +describe("CollapsibleSection", () => { + let wrapper; + + function setup(props = {}) { + const customProps = Object.assign({}, DEFAULT_PROPS, props); + wrapper = mount( + <CollapsibleSection {...customProps}>foo</CollapsibleSection> + ); + } + + beforeEach(() => setup()); + + it("should render the component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render an ErrorBoundary with class section-body-fallback", () => { + assert.equal( + wrapper.find(ErrorBoundary).first().prop("className"), + "section-body-fallback" + ); + }); + + describe("without collapsible pref", () => { + let dispatch; + beforeEach(() => { + dispatch = sinon.stub(); + setup({ collapsed: undefined, dispatch }); + }); + it("should render the section uncollapsed", () => { + assert.isFalse( + wrapper.find(".collapsible-section").first().hasClass("collapsed") + ); + }); + + it("should not render the arrow if no collapsible pref exists for the section", () => { + assert.lengthOf(wrapper.find(".click-target .collapsible-arrow"), 0); + }); + }); + + describe("icon", () => { + it("no icon should be shown", () => { + assert.lengthOf(wrapper.find(".icon"), 0); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx new file mode 100644 index 0000000000..baf203947e --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/ComponentPerfTimer.test.jsx @@ -0,0 +1,447 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import createMockRaf from "mock-raf"; +import React from "react"; + +import { shallow } from "enzyme"; + +const perfSvc = { + mark() {}, + getMostRecentAbsMarkStartByName() {}, +}; + +let DEFAULT_PROPS = { + initialized: true, + rows: [], + id: "highlights", + dispatch() {}, + perfSvc, +}; + +describe("<ComponentPerfTimer>", () => { + let mockRaf; + let sandbox; + let wrapper; + + const InnerEl = () => <div>Inner Element</div>; + + beforeEach(() => { + mockRaf = createMockRaf(); + sandbox = sinon.createSandbox(); + sandbox.stub(window, "requestAnimationFrame").callsFake(mockRaf.raf); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should render props.children", () => { + assert.ok(wrapper.contains(<InnerEl />)); + }); + + describe("#constructor", () => { + beforeEach(() => { + sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent"); + sandbox.stub( + ComponentPerfTimer.prototype, + "_ensureFirstRenderTsRecorded" + ); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer>, + { disableLifecycleMethods: true } + ); + }); + + it("should have the correct defaults", () => { + const instance = wrapper.instance(); + + assert.isFalse(instance._reportMissingData); + assert.isFalse(instance._timestampHandled); + assert.isFalse(instance._recordedFirstRender); + }); + }); + + describe("#render", () => { + beforeEach(() => { + sandbox.stub(DEFAULT_PROPS, "id").value("fake_section"); + sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent"); + sandbox.stub( + ComponentPerfTimer.prototype, + "_ensureFirstRenderTsRecorded" + ); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + }); + + it("should not call telemetry on sections that we don't want to record", () => { + const instance = wrapper.instance(); + + assert.notCalled(instance._maybeSendBadStateEvent); + assert.notCalled(instance._ensureFirstRenderTsRecorded); + }); + }); + + describe("#_componentDidMount", () => { + it("should call _maybeSendPaintedEvent", () => { + const instance = wrapper.instance(); + const stub = sandbox.stub(instance, "_maybeSendPaintedEvent"); + + instance.componentDidMount(); + + assert.calledOnce(stub); + }); + + it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => { + sandbox.stub(DEFAULT_PROPS, "id").value("topstories"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + const stub = sandbox.stub(instance, "_maybeSendPaintedEvent"); + + instance.componentDidMount(); + + assert.notCalled(stub); + }); + }); + + describe("#_componentDidUpdate", () => { + it("should call _maybeSendPaintedEvent", () => { + const instance = wrapper.instance(); + const maybeSendPaintStub = sandbox.stub( + instance, + "_maybeSendPaintedEvent" + ); + + instance.componentDidUpdate(); + + assert.calledOnce(maybeSendPaintStub); + }); + + it("should not call _maybeSendPaintedEvent if id not in RECORDED_SECTIONS", () => { + sandbox.stub(DEFAULT_PROPS, "id").value("topstories"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + const stub = sandbox.stub(instance, "_maybeSendPaintedEvent"); + + instance.componentDidUpdate(); + + assert.notCalled(stub); + }); + }); + + describe("_ensureFirstRenderTsRecorded", () => { + let recordFirstRenderStub; + beforeEach(() => { + sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent"); + recordFirstRenderStub = sandbox.stub( + ComponentPerfTimer.prototype, + "_ensureFirstRenderTsRecorded" + ); + }); + + it("should set _recordedFirstRender", () => { + sandbox.stub(DEFAULT_PROPS, "initialized").value(false); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + + assert.isFalse(instance._recordedFirstRender); + + recordFirstRenderStub.callThrough(); + instance._ensureFirstRenderTsRecorded(); + + assert.isTrue(instance._recordedFirstRender); + }); + + it("should mark first_render_ts", () => { + sandbox.stub(DEFAULT_PROPS, "initialized").value(false); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + const stub = sandbox.stub(perfSvc, "mark"); + + recordFirstRenderStub.callThrough(); + instance._ensureFirstRenderTsRecorded(); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, `${DEFAULT_PROPS.id}_first_render_ts`); + }); + }); + + describe("#_maybeSendBadStateEvent", () => { + let sendBadStateStub; + beforeEach(() => { + sendBadStateStub = sandbox.stub( + ComponentPerfTimer.prototype, + "_maybeSendBadStateEvent" + ); + sandbox.stub( + ComponentPerfTimer.prototype, + "_ensureFirstRenderTsRecorded" + ); + }); + + it("should set this._reportMissingData=true when called with initialized === false", () => { + sandbox.stub(DEFAULT_PROPS, "initialized").value(false); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + + assert.isFalse(instance._reportMissingData); + + sendBadStateStub.callThrough(); + instance._maybeSendBadStateEvent(); + + assert.isTrue(instance._reportMissingData); + }); + + it("should call _sendBadStateEvent if initialized & other metrics have been recorded", () => { + const instance = wrapper.instance(); + const stub = sandbox.stub(instance, "_sendBadStateEvent"); + instance._reportMissingData = true; + instance._timestampHandled = true; + instance._recordedFirstRender = true; + + sendBadStateStub.callThrough(); + instance._maybeSendBadStateEvent(); + + assert.calledOnce(stub); + assert.isFalse(instance._reportMissingData); + }); + }); + + describe("#_maybeSendPaintedEvent", () => { + it("should call _sendPaintedEvent if props.initialized is true", () => { + sandbox.stub(DEFAULT_PROPS, "initialized").value(true); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer>, + { disableLifecycleMethods: true } + ); + const instance = wrapper.instance(); + const stub = sandbox.stub(instance, "_afterFramePaint"); + + assert.isFalse(instance._timestampHandled); + + instance._maybeSendPaintedEvent(); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, instance._sendPaintedEvent); + assert.isTrue(wrapper.instance()._timestampHandled); + }); + it("should not call _sendPaintedEvent if this._timestampHandled is true", () => { + const instance = wrapper.instance(); + const spy = sinon.spy(instance, "_afterFramePaint"); + instance._timestampHandled = true; + + instance._maybeSendPaintedEvent(); + spy.neverCalledWith(instance._sendPaintedEvent); + }); + it("should not call _sendPaintedEvent if component not initialized", () => { + sandbox.stub(DEFAULT_PROPS, "initialized").value(false); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + const instance = wrapper.instance(); + const spy = sinon.spy(instance, "_afterFramePaint"); + + instance._maybeSendPaintedEvent(); + + spy.neverCalledWith(instance._sendPaintedEvent); + }); + }); + + describe("#_afterFramePaint", () => { + it("should call callback after the requestAnimationFrame callback returns", () => + new Promise(resolve => { + // Setting the callback to resolve is the test that it does finally get + // called at the correct time, after the event loop ticks again. + // If it doesn't get called, this test will time out. + const callback = sandbox.spy(resolve); + + const instance = wrapper.instance(); + + instance._afterFramePaint(callback); + + assert.notCalled(callback); + mockRaf.step({ count: 1 }); + })); + }); + + describe("#_sendBadStateEvent", () => { + it("should call perfSvc.mark", () => { + sandbox.spy(perfSvc, "mark"); + const key = `${DEFAULT_PROPS.id}_data_ready_ts`; + + wrapper.instance()._sendBadStateEvent(); + + assert.calledOnce(perfSvc.mark); + assert.calledWithExactly(perfSvc.mark, key); + }); + + it("should call compute the delta from first render to data ready", () => { + sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName"); + + wrapper + .instance() + ._sendBadStateEvent(`${DEFAULT_PROPS.id}_data_ready_ts`); + + assert.calledTwice(perfSvc.getMostRecentAbsMarkStartByName); + assert.calledWithExactly( + perfSvc.getMostRecentAbsMarkStartByName, + `${DEFAULT_PROPS.id}_data_ready_ts` + ); + assert.calledWithExactly( + perfSvc.getMostRecentAbsMarkStartByName, + `${DEFAULT_PROPS.id}_first_render_ts` + ); + }); + + it("should call dispatch SAVE_SESSION_PERF_DATA", () => { + sandbox + .stub(perfSvc, "getMostRecentAbsMarkStartByName") + .withArgs("highlights_first_render_ts") + .returns(0.5) + .withArgs("highlights_data_ready_ts") + .returns(3.2); + + const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendBadStateEvent(); + + assert.calledOnce(dispatch); + assert.calledWithExactly( + dispatch, + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { [`${DEFAULT_PROPS.id}_data_late_by_ms`]: 2 }, + }) + ); + }); + }); + + describe("#_sendPaintedEvent", () => { + beforeEach(() => { + sandbox.stub(ComponentPerfTimer.prototype, "_maybeSendBadStateEvent"); + sandbox.stub( + ComponentPerfTimer.prototype, + "_ensureFirstRenderTsRecorded" + ); + }); + + it("should not call mark with the wrong id", () => { + sandbox.stub(perfSvc, "mark"); + sandbox.stub(DEFAULT_PROPS, "id").value("fake_id"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendPaintedEvent(); + + assert.notCalled(perfSvc.mark); + }); + it("should call mark with the correct topsites", () => { + sandbox.stub(perfSvc, "mark"); + sandbox.stub(DEFAULT_PROPS, "id").value("topsites"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendPaintedEvent(); + + assert.calledOnce(perfSvc.mark); + assert.calledWithExactly(perfSvc.mark, "topsites_first_painted_ts"); + }); + it("should not call getMostRecentAbsMarkStartByName if id!=topsites", () => { + sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName"); + sandbox.stub(DEFAULT_PROPS, "id").value("fake_id"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendPaintedEvent(); + + assert.notCalled(perfSvc.getMostRecentAbsMarkStartByName); + }); + it("should call getMostRecentAbsMarkStartByName for topsites", () => { + sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName"); + sandbox.stub(DEFAULT_PROPS, "id").value("topsites"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendPaintedEvent(); + + assert.calledOnce(perfSvc.getMostRecentAbsMarkStartByName); + assert.calledWithExactly( + perfSvc.getMostRecentAbsMarkStartByName, + "topsites_first_painted_ts" + ); + }); + it("should dispatch SAVE_SESSION_PERF_DATA", () => { + sandbox.stub(perfSvc, "getMostRecentAbsMarkStartByName").returns(42); + sandbox.stub(DEFAULT_PROPS, "id").value("topsites"); + const dispatch = sandbox.spy(DEFAULT_PROPS, "dispatch"); + wrapper = shallow( + <ComponentPerfTimer {...DEFAULT_PROPS}> + <InnerEl /> + </ComponentPerfTimer> + ); + + wrapper.instance()._sendPaintedEvent(); + + assert.calledOnce(dispatch); + assert.calledWithExactly( + dispatch, + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { topsites_first_painted_ts: 42 }, + }) + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx new file mode 100644 index 0000000000..a471c09e66 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/ConfirmDialog.test.jsx @@ -0,0 +1,182 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { _ConfirmDialog as ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<ConfirmDialog>", () => { + let wrapper; + let dispatch; + let ConfirmDialogProps; + beforeEach(() => { + dispatch = sinon.stub(); + ConfirmDialogProps = { + visible: true, + data: { + onConfirm: [], + cancel_button_string_id: "newtab-topsites-delete-history-button", + confirm_button_string_id: "newtab-topsites-cancel-button", + eventSource: "HIGHLIGHTS", + }, + }; + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + }); + it("should render an overlay", () => { + assert.ok(wrapper.find(".modal-overlay").exists()); + }); + it("should render a modal", () => { + assert.ok(wrapper.find(".confirmation-dialog").exists()); + }); + it("should not render if visible is false", () => { + ConfirmDialogProps.visible = false; + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + + assert.lengthOf(wrapper.find(".confirmation-dialog"), 0); + }); + it("should display an icon if we provide one in props", () => { + const iconName = "modal-icon"; + // If there is no icon in the props, we shouldn't display an icon + assert.lengthOf(wrapper.find(`.icon-${iconName}`), 0); + + ConfirmDialogProps.data.icon = iconName; + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + + // But if we do provide an icon - we should show it + assert.lengthOf(wrapper.find(`.icon-${iconName}`), 1); + }); + describe("fluent message check", () => { + it("should render the message body sent via props", () => { + Object.assign(ConfirmDialogProps.data, { + body_string_id: ["foo", "bar"], + }); + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + let msgs = wrapper.find(".modal-message").find("p"); + assert.equal(msgs.length, ConfirmDialogProps.data.body_string_id.length); + msgs.forEach((fm, i) => + assert.equal( + fm.prop("data-l10n-id"), + ConfirmDialogProps.data.body_string_id[i] + ) + ); + }); + it("should render the correct primary button text", () => { + Object.assign(ConfirmDialogProps.data, { + confirm_button_string_id: "primary_foo", + }); + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + + let doneLabel = wrapper.find(".actions").childAt(1); + assert.ok(doneLabel.exists()); + assert.equal( + doneLabel.prop("data-l10n-id"), + ConfirmDialogProps.data.confirm_button_string_id + ); + }); + }); + describe("click events", () => { + it("should emit AlsoToMain DIALOG_CANCEL when you click the overlay", () => { + let overlay = wrapper.find(".modal-overlay"); + + assert.ok(overlay.exists()); + overlay.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.calledTwice(dispatch); + assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL); + assert.calledWith(dispatch, { type: at.DIALOG_CANCEL }); + }); + it("should emit UserEvent DIALOG_CANCEL when you click the overlay", () => { + let overlay = wrapper.find(".modal-overlay"); + + assert.ok(overlay); + overlay.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.calledTwice(dispatch); + assert.isUserEventAction(dispatch.secondCall.args[0]); + assert.calledWith( + dispatch, + ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" }) + ); + }); + it("should emit AlsoToMain DIALOG_CANCEL on cancel", () => { + let cancelButton = wrapper.find(".actions").childAt(0); + + assert.ok(cancelButton); + cancelButton.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.calledTwice(dispatch); + assert.propertyVal(dispatch.firstCall.args[0], "type", at.DIALOG_CANCEL); + assert.calledWith(dispatch, { type: at.DIALOG_CANCEL }); + }); + it("should emit UserEvent DIALOG_CANCEL on cancel", () => { + let cancelButton = wrapper.find(".actions").childAt(0); + + assert.ok(cancelButton); + cancelButton.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.calledTwice(dispatch); + assert.isUserEventAction(dispatch.secondCall.args[0]); + assert.calledWith( + dispatch, + ac.UserEvent({ event: at.DIALOG_CANCEL, source: "HIGHLIGHTS" }) + ); + }); + it("should emit UserEvent on primary button", () => { + Object.assign(ConfirmDialogProps.data, { + body_string_id: ["foo", "bar"], + onConfirm: [ + ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }), + ac.UserEvent({ event: "DELETE" }), + ], + }); + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + let doneButton = wrapper.find(".actions").childAt(1); + + assert.ok(doneButton); + doneButton.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.isUserEventAction(dispatch.secondCall.args[0]); + + assert.calledTwice(dispatch); + assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[1]); + }); + it("should emit AlsoToMain on primary button", () => { + Object.assign(ConfirmDialogProps.data, { + body_string_id: ["foo", "bar"], + onConfirm: [ + ac.AlsoToMain({ type: at.DELETE_URL, data: "foo.bar" }), + ac.UserEvent({ event: "DELETE" }), + ], + }); + wrapper = shallow( + <ConfirmDialog dispatch={dispatch} {...ConfirmDialogProps} /> + ); + let doneButton = wrapper.find(".actions").childAt(1); + + assert.ok(doneButton); + doneButton.simulate("click"); + + // Two events are emitted: UserEvent+AlsoToMain. + assert.calledTwice(dispatch); + assert.calledWith(dispatch, ConfirmDialogProps.data.onConfirm[0]); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx new file mode 100644 index 0000000000..4f7edadc41 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/ContextMenu.test.jsx @@ -0,0 +1,227 @@ +import { + ContextMenu, + ContextMenuItem, + _ContextMenuItem, +} from "content-src/components/ContextMenu/ContextMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { mount, shallow } from "enzyme"; +import React from "react"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { Provider } from "react-redux"; +import { combineReducers, createStore } from "redux"; + +const DEFAULT_PROPS = { + onUpdate: () => {}, + options: [], + tabbableOptionsLength: 0, +}; + +const DEFAULT_MENU_OPTIONS = [ + "MoveUp", + "MoveDown", + "Separator", + "ManageSection", +]; + +const FakeMenu = props => { + return <div>{props.children}</div>; +}; + +describe("<ContextMenuButton>", () => { + function mountWithProps(options) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <ContextMenuButton> + <ContextMenu options={options} /> + </ContextMenuButton> + </Provider> + ); + } + + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should call onUpdate when clicked", () => { + const onUpdate = sandbox.spy(); + const wrapper = mount( + <ContextMenuButton onUpdate={onUpdate}> + <FakeMenu /> + </ContextMenuButton> + ); + wrapper.find(".context-menu-button").simulate("click"); + assert.calledOnce(onUpdate); + }); + it("should call onUpdate when activated with Enter", () => { + const onUpdate = sandbox.spy(); + const wrapper = mount( + <ContextMenuButton onUpdate={onUpdate}> + <FakeMenu /> + </ContextMenuButton> + ); + wrapper.find(".context-menu-button").simulate("keydown", { key: "Enter" }); + assert.calledOnce(onUpdate); + }); + it("should call onClick", () => { + const onClick = sandbox.spy(ContextMenuButton.prototype, "onClick"); + const wrapper = mount( + <ContextMenuButton> + <FakeMenu /> + </ContextMenuButton> + ); + wrapper.find("button").simulate("click"); + assert.calledOnce(onClick); + }); + it("should have a default keyboardAccess prop of false", () => { + const wrapper = mountWithProps(DEFAULT_MENU_OPTIONS); + wrapper.find(ContextMenuButton).setState({ showContextMenu: true }); + assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), false); + }); + it("should pass the keyboardAccess prop down to ContextMenu", () => { + const wrapper = mountWithProps(DEFAULT_MENU_OPTIONS); + wrapper + .find(ContextMenuButton) + .setState({ showContextMenu: true, contextMenuKeyboard: true }); + assert.equal(wrapper.find(ContextMenu).prop("keyboardAccess"), true); + }); + it("should call focusFirst when keyboardAccess is true", () => { + const options = [{ label: "item1", first: true }]; + const wrapper = mountWithProps(options); + const focusFirst = sandbox.spy(_ContextMenuItem.prototype, "focusFirst"); + wrapper + .find(ContextMenuButton) + .setState({ showContextMenu: true, contextMenuKeyboard: true }); + assert.calledOnce(focusFirst); + }); +}); + +describe("<ContextMenu>", () => { + function mountWithProps(props) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <ContextMenu {...props} /> + </Provider> + ); + } + + it("should render all the options provided", () => { + const options = [ + { label: "item1" }, + { type: "separator" }, + { label: "item2" }, + ]; + const wrapper = shallow( + <ContextMenu {...DEFAULT_PROPS} options={options} /> + ); + assert.lengthOf(wrapper.find(".context-menu-list").children(), 3); + }); + it("should not add a link for a separator", () => { + const options = [{ label: "item1" }, { type: "separator" }]; + const wrapper = shallow( + <ContextMenu {...DEFAULT_PROPS} options={options} /> + ); + assert.lengthOf(wrapper.find(".separator"), 1); + }); + it("should add a link for all types that are not separators", () => { + const options = [{ label: "item1" }, { type: "separator" }]; + const wrapper = shallow( + <ContextMenu {...DEFAULT_PROPS} options={options} /> + ); + assert.lengthOf(wrapper.find(ContextMenuItem), 1); + }); + it("should not add an icon to any items", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + options: [{ label: "item1", icon: "icon1" }, { type: "separator" }], + }); + const wrapper = mountWithProps(props); + assert.lengthOf(wrapper.find(".icon-icon1"), 0); + }); + it("should be tabbable", () => { + const props = { + options: [{ label: "item1", icon: "icon1" }, { type: "separator" }], + }; + const wrapper = mountWithProps(props); + assert.equal( + wrapper.find(".context-menu-item").props().role, + "presentation" + ); + }); + it("should call onUpdate with false when an option is clicked", () => { + const onUpdate = sinon.spy(); + const onClick = sinon.spy(); + const props = Object.assign({}, DEFAULT_PROPS, { + onUpdate, + options: [{ label: "item1", onClick }], + }); + const wrapper = mountWithProps(props); + wrapper.find(".context-menu-item button").simulate("click"); + assert.calledOnce(onUpdate); + assert.calledOnce(onClick); + }); + it("should not have disabled className by default", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + options: [{ label: "item1", icon: "icon1" }, { type: "separator" }], + }); + const wrapper = mountWithProps(props); + assert.lengthOf(wrapper.find(".context-menu-item a.disabled"), 0); + }); + it("should add disabled className to any disabled options", () => { + const options = [ + { label: "item1", icon: "icon1", disabled: true }, + { type: "separator" }, + ]; + const props = Object.assign({}, DEFAULT_PROPS, { options }); + const wrapper = mountWithProps(props); + assert.lengthOf(wrapper.find(".context-menu-item button.disabled"), 1); + }); + it("should have the context-menu-item class", () => { + const options = [{ label: "item1", icon: "icon1" }]; + const props = Object.assign({}, DEFAULT_PROPS, { options }); + const wrapper = mountWithProps(props); + assert.lengthOf(wrapper.find(".context-menu-item"), 1); + }); + it("should call onClick when onKeyDown is called with Enter", () => { + const onClick = sinon.spy(); + const props = Object.assign({}, DEFAULT_PROPS, { + options: [{ label: "item1", onClick }], + }); + const wrapper = mountWithProps(props); + wrapper + .find(".context-menu-item button") + .simulate("keydown", { key: "Enter" }); + assert.calledOnce(onClick); + }); + it("should call focusSibling when onKeyDown is called with ArrowUp", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + options: [{ label: "item1" }], + }); + const wrapper = mountWithProps(props); + const focusSibling = sinon.stub( + wrapper.find(_ContextMenuItem).instance(), + "focusSibling" + ); + wrapper + .find(".context-menu-item button") + .simulate("keydown", { key: "ArrowUp" }); + assert.calledOnce(focusSibling); + }); + it("should call focusSibling when onKeyDown is called with ArrowDown", () => { + const props = Object.assign({}, DEFAULT_PROPS, { + options: [{ label: "item1" }], + }); + const wrapper = mountWithProps(props); + const focusSibling = sinon.stub( + wrapper.find(_ContextMenuItem).instance(), + "focusSibling" + ); + wrapper + .find(".context-menu-item button") + .simulate("keydown", { key: "ArrowDown" }); + assert.calledOnce(focusSibling); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx new file mode 100644 index 0000000000..b4cf2b1261 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/CustomiseMenu.test.jsx @@ -0,0 +1,72 @@ +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; +import { mount } from "enzyme"; +import React from "react"; + +const DEFAULT_PROPS = { + enabledSections: { + pocketEnabled: true, + topSitesEnabled: true, + }, + mayHaveSponsoredTopSites: true, + mayHaveSponsoredStories: true, + pocketRegion: true, + dispatch: sinon.stub(), + setPref: sinon.stub(), +}; + +describe("ContentSection", () => { + let wrapper; + beforeEach(() => { + wrapper = mount(<ContentSection {...DEFAULT_PROPS} />); + }); + + it("should render the component", () => { + assert.ok(wrapper.exists()); + }); + + it("should look for an eventSource attribute and dispatch an event for INPUT", () => { + wrapper.instance().onPreferenceSelect({ + target: { + nodeName: "INPUT", + checked: true, + getAttribute: eventSource => + eventSource === "eventSource" ? "foo" : null, + }, + }); + + assert.calledWith( + DEFAULT_PROPS.dispatch, + ac.UserEvent({ + event: "PREF_CHANGED", + source: "foo", + value: { status: true, menu_source: "CUSTOMIZE_MENU" }, + }) + ); + wrapper.unmount(); + }); + + it("should have eventSource attributes on relevent pref changing inputs", () => { + wrapper = mount(<ContentSection {...DEFAULT_PROPS} />); + assert.equal( + wrapper.find("#shortcuts-toggle").prop("eventSource"), + "TOP_SITES" + ); + assert.equal( + wrapper.find("#sponsored-shortcuts").prop("eventSource"), + "SPONSORED_TOP_SITES" + ); + assert.equal( + wrapper.find("#pocket-toggle").prop("eventSource"), + "TOP_STORIES" + ); + assert.equal( + wrapper.find("#sponsored-pocket").prop("eventSource"), + "POCKET_SPOCS" + ); + assert.equal( + wrapper.find("#highlights-toggle").prop("eventSource"), + "HIGHLIGHTS" + ); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx new file mode 100644 index 0000000000..7720e07327 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamBase.test.jsx @@ -0,0 +1,313 @@ +import { + _DiscoveryStreamBase as DiscoveryStreamBase, + isAllowedCSS, +} from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase"; +import { GlobalOverrider } from "test/unit/utils"; +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import React from "react"; +import { shallow } from "enzyme"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +describe("<isAllowedCSS>", () => { + it("should allow colors", () => { + assert.isTrue(isAllowedCSS("color", "red")); + }); + + it("should allow chrome urls", () => { + assert.isTrue( + isAllowedCSS( + "background-image", + `url("chrome://global/skin/icons/info.svg")` + ) + ); + }); + + it("should allow chrome urls", () => { + assert.isTrue( + isAllowedCSS( + "background-image", + `url("chrome://browser/skin/history.svg")` + ) + ); + }); + + it("should allow allowed https urls", () => { + assert.isTrue( + isAllowedCSS( + "background-image", + `url("https://img-getpocket.cdn.mozilla.net/media/image.png")` + ) + ); + }); + + it("should disallow other https urls", () => { + assert.isFalse( + isAllowedCSS( + "background-image", + `url("https://mozilla.org/media/image.png")` + ) + ); + }); + + it("should disallow other protocols", () => { + assert.isFalse( + isAllowedCSS( + "background-image", + `url("ftp://mozilla.org/media/image.png")` + ) + ); + }); + + it("should allow allowed multiple valid urls", () => { + assert.isTrue( + isAllowedCSS( + "background-image", + `url("https://img-getpocket.cdn.mozilla.net/media/image.png"), url("chrome://browser/skin/history.svg")` + ) + ); + }); + + it("should disallow if any invaild", () => { + assert.isFalse( + isAllowedCSS( + "background-image", + `url("chrome://browser/skin/history.svg"), url("ftp://mozilla.org/media/image.png")` + ) + ); + }); +}); + +describe("<DiscoveryStreamBase>", () => { + let wrapper; + let globals; + let sandbox; + + function mountComponent(props = {}) { + const defaultProps = { + config: { collapsible: true }, + layout: [], + feeds: { loaded: true }, + spocs: { + loaded: true, + data: { spocs: null }, + }, + ...props, + }; + return shallow( + <DiscoveryStreamBase + locale="en-US" + DiscoveryStream={defaultProps} + Prefs={{ + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "feeds.topsites": true, + }, + }} + App={{ + locale: "en-US", + }} + document={{ + documentElement: { lang: "en-US" }, + }} + Sections={[ + { + id: "topstories", + learnMore: { link: {} }, + pref: {}, + }, + ]} + /> + ); + } + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + wrapper = mountComponent(); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should render something if spocs are not loaded", () => { + wrapper = mountComponent({ + spocs: { loaded: false, data: { spocs: null } }, + }); + + assert.notEqual(wrapper.type(), null); + }); + + it("should render something if feeds are not loaded", () => { + wrapper = mountComponent({ feeds: { loaded: false } }); + + assert.notEqual(wrapper.type(), null); + }); + + it("should render nothing with no layout", () => { + assert.ok(wrapper.exists()); + assert.isEmpty(wrapper.children()); + }); + + it("should render a HorizontalRule component", () => { + wrapper = mountComponent({ + layout: [{ components: [{ type: "HorizontalRule" }] }], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + HorizontalRule + ); + }); + + it("should render a CardGrid component", () => { + wrapper = mountComponent({ + layout: [{ components: [{ properties: {}, type: "CardGrid" }] }], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + CardGrid + ); + }); + + it("should render a Navigation component", () => { + wrapper = mountComponent({ + layout: [{ components: [{ properties: {}, type: "Navigation" }] }], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + Navigation + ); + }); + + it("should render nothing if there was only a Message", () => { + wrapper = mountComponent({ + layout: [ + { components: [{ header: {}, properties: {}, type: "Message" }] }, + ], + }); + + assert.isEmpty(wrapper.children()); + }); + + it("should render a regular Message when not collapsible", () => { + wrapper = mountComponent({ + config: { collapsible: false }, + layout: [ + { components: [{ header: {}, properties: {}, type: "Message" }] }, + ], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + DSMessage + ); + }); + + it("should convert first Message component to CollapsibleSection", () => { + wrapper = mountComponent({ + layout: [ + { + components: [ + { header: {}, properties: {}, type: "Message" }, + { type: "HorizontalRule" }, + ], + }, + ], + }); + + assert.equal(wrapper.children().at(0).type(), CollapsibleSection); + assert.equal(wrapper.children().at(0).props().eventSource, "CARDGRID"); + }); + + it("should render a Message component", () => { + wrapper = mountComponent({ + layout: [ + { + components: [ + { header: {}, type: "Message" }, + { properties: {}, type: "Message" }, + ], + }, + ], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + DSMessage + ); + }); + + it("should render a SectionTitle component", () => { + wrapper = mountComponent({ + layout: [{ components: [{ properties: {}, type: "SectionTitle" }] }], + }); + + assert.equal( + wrapper.find(".ds-column-grid div").children().at(0).type(), + SectionTitle + ); + }); + + it("should render TopSites", () => { + wrapper = mountComponent({ + layout: [{ components: [{ properties: {}, type: "TopSites" }] }], + }); + + assert.equal( + wrapper + .find(".ds-column-grid div") + .find(".ds-top-sites") + .children() + .at(0) + .type(), + TopSites + ); + }); + + describe("#onStyleMount", () => { + let parseStub; + + beforeEach(() => { + parseStub = sandbox.stub(); + globals.set("JSON", { parse: parseStub }); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should return if no style", () => { + assert.isUndefined(wrapper.instance().onStyleMount()); + assert.notCalled(parseStub); + }); + + it("should insert rules", () => { + const sheetStub = { insertRule: sandbox.stub(), cssRules: [{}] }; + parseStub.returns([ + [ + null, + { + ".ds-message": "margin-bottom: -20px", + }, + null, + null, + ], + ]); + wrapper.instance().onStyleMount({ sheet: sheetStub, dataset: {} }); + + assert.calledOnce(sheetStub.insertRule); + assert.calledWithExactly(sheetStub.insertRule, "DUMMY#CSS.SELECTOR {}"); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx new file mode 100644 index 0000000000..418a731ba1 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CardGrid.test.jsx @@ -0,0 +1,354 @@ +import { + _CardGrid as CardGrid, + IntersectionObserver, + RecentSavesContainer, + OnboardingExperience, + DSSubHeader, +} from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { combineReducers, createStore } from "redux"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { Provider } from "react-redux"; +import { + DSCard, + PlaceholderDSCard, +} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard"; +import { TopicsWidget } from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React from "react"; +import { shallow, mount } from "enzyme"; + +// Wrap this around any component that uses useSelector, +// or any mount that uses a child that uses redux. +function WrapWithProvider({ children, state = INITIAL_STATE }) { + let store = createStore(combineReducers(reducers), state); + return <Provider store={store}>{children}</Provider>; +} + +describe("<CardGrid>", () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow( + <CardGrid + Prefs={INITIAL_STATE.Prefs} + DiscoveryStream={INITIAL_STATE.DiscoveryStream} + /> + ); + }); + + it("should render an empty div", () => { + assert.ok(wrapper.exists()); + assert.lengthOf(wrapper.children(), 0); + }); + + it("should render DSCards", () => { + wrapper.setProps({ items: 2, data: { recommendations: [{}, {}] } }); + + assert.lengthOf(wrapper.find(".ds-card-grid").children(), 2); + assert.equal(wrapper.find(".ds-card-grid").children().at(0).type(), DSCard); + }); + + it("should add 4 card classname to card grid", () => { + wrapper.setProps({ + fourCardLayout: true, + data: { recommendations: [{}, {}] }, + }); + + assert.ok(wrapper.find(".ds-card-grid-four-card-variant").exists()); + }); + + it("should add no description classname to card grid", () => { + wrapper.setProps({ + hideCardBackground: true, + data: { recommendations: [{}, {}] }, + }); + + assert.ok(wrapper.find(".ds-card-grid-hide-background").exists()); + }); + + it("should render sub header in the middle of the card grid for both regular and compact", () => { + const commonProps = { + essentialReadsHeader: true, + editorsPicksHeader: true, + items: 12, + data: { + recommendations: [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}], + }, + Prefs: INITIAL_STATE.Prefs, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, + }; + wrapper = mount( + <WrapWithProvider> + <CardGrid {...commonProps} /> + </WrapWithProvider> + ); + + assert.ok(wrapper.find(DSSubHeader).exists()); + + wrapper.setProps({ + compact: true, + }); + wrapper = mount( + <WrapWithProvider> + <CardGrid {...commonProps} compact={true} /> + </WrapWithProvider> + ); + + assert.ok(wrapper.find(DSSubHeader).exists()); + }); + + it("should add/hide description classname to card grid", () => { + wrapper.setProps({ + data: { recommendations: [{}, {}] }, + }); + + assert.ok(wrapper.find(".ds-card-grid-include-descriptions").exists()); + + wrapper.setProps({ + hideDescriptions: true, + data: { recommendations: [{}, {}] }, + }); + + assert.ok(!wrapper.find(".ds-card-grid-include-descriptions").exists()); + }); + + it("should create a widget card", () => { + wrapper.setProps({ + widgets: { + positions: [{ index: 1 }], + data: [{ type: "TopicsWidget" }], + }, + data: { + recommendations: [{}, {}, {}], + }, + }); + + assert.ok(wrapper.find(TopicsWidget).exists()); + }); +}); + +// Build IntersectionObserver class with the arg `entries` for the intersect callback. +function buildIntersectionObserver(entries) { + return class { + constructor(callback) { + this.callback = callback; + } + + observe() { + this.callback(entries); + } + + unobserve() {} + + disconnect() {} + }; +} + +describe("<IntersectionObserver>", () => { + let wrapper; + let fakeWindow; + let intersectEntries; + + beforeEach(() => { + intersectEntries = [{ isIntersecting: true }]; + fakeWindow = { + IntersectionObserver: buildIntersectionObserver(intersectEntries), + }; + wrapper = mount(<IntersectionObserver windowObj={fakeWindow} />); + }); + + it("should render an empty div", () => { + assert.ok(wrapper.exists()); + assert.equal(wrapper.children().at(0).type(), "div"); + }); + + it("should fire onIntersecting", () => { + const onIntersecting = sinon.stub(); + wrapper = mount( + <IntersectionObserver + windowObj={fakeWindow} + onIntersecting={onIntersecting} + /> + ); + assert.calledOnce(onIntersecting); + }); +}); + +describe("<RecentSavesContainer>", () => { + let wrapper; + let fakeWindow; + let intersectEntries; + let dispatch; + + beforeEach(() => { + dispatch = sinon.stub(); + intersectEntries = [{ isIntersecting: true }]; + fakeWindow = { + IntersectionObserver: buildIntersectionObserver(intersectEntries), + }; + wrapper = mount( + <WrapWithProvider + state={{ + DiscoveryStream: { + isUserLoggedIn: true, + recentSavesData: [ + { + resolved_id: "resolved_id", + top_image_url: "top_image_url", + title: "title", + resolved_url: "https://resolved_url", + domain: "domain", + excerpt: "excerpt", + }, + ], + experimentData: { + utmSource: "utmSource", + utmContent: "utmContent", + utmCampaign: "utmCampaign", + }, + }, + }} + > + <RecentSavesContainer + gridClassName="ds-card-grid" + windowObj={fakeWindow} + dispatch={dispatch} + /> + </WrapWithProvider> + ).find(RecentSavesContainer); + }); + + it("should render an IntersectionObserver when not visible", () => { + intersectEntries = [{ isIntersecting: false }]; + fakeWindow = { + IntersectionObserver: buildIntersectionObserver(intersectEntries), + }; + wrapper = mount( + <WrapWithProvider> + <RecentSavesContainer windowObj={fakeWindow} dispatch={dispatch} /> + </WrapWithProvider> + ).find(RecentSavesContainer); + + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(IntersectionObserver).exists()); + }); + + it("should render nothing if visible until we log in", () => { + assert.ok(!wrapper.find(IntersectionObserver).exists()); + assert.calledOnce(dispatch); + assert.calledWith( + dispatch, + ac.AlsoToMain({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + }) + ); + }); + + it("should render a grid if visible and logged in", () => { + assert.lengthOf(wrapper.find(".ds-card-grid"), 1); + assert.lengthOf(wrapper.find(DSSubHeader), 1); + assert.lengthOf(wrapper.find(PlaceholderDSCard), 2); + assert.lengthOf(wrapper.find(DSCard), 3); + }); + + it("should render a my list link with proper utm params", () => { + assert.equal( + wrapper.find(".section-sub-link").at(0).prop("url"), + "https://getpocket.com/a?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign" + ); + }); + + it("should fire a UserEvent for my list clicks", () => { + wrapper.find(".section-sub-link").at(0).simulate("click"); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: `CARDGRID_RECENT_SAVES_VIEW_LIST`, + }) + ); + }); +}); + +describe("<OnboardingExperience>", () => { + let wrapper; + let fakeWindow; + let intersectEntries; + let dispatch; + let resizeCallback; + + let fakeResizeObserver = class { + constructor(callback) { + resizeCallback = callback; + } + + observe() {} + + unobserve() {} + + disconnect() {} + }; + + beforeEach(() => { + dispatch = sinon.stub(); + intersectEntries = [{ isIntersecting: true, intersectionRatio: 1 }]; + fakeWindow = { + ResizeObserver: fakeResizeObserver, + IntersectionObserver: buildIntersectionObserver(intersectEntries), + document: { + visibilityState: "visible", + addEventListener: () => {}, + removeEventListener: () => {}, + }, + }; + wrapper = mount( + <WrapWithProvider state={{}}> + <OnboardingExperience windowObj={fakeWindow} dispatch={dispatch} /> + </WrapWithProvider> + ).find(OnboardingExperience); + }); + + it("should render a ds-onboarding", () => { + assert.ok(wrapper.exists()); + assert.lengthOf(wrapper.find(".ds-onboarding"), 1); + }); + + it("should dismiss on dismiss click", () => { + wrapper.find(".ds-dismiss-button").simulate("click"); + + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "BLOCK", + source: "POCKET_ONBOARDING", + }) + ); + assert.calledWith( + dispatch, + ac.SetPref("discoverystream.onboardingExperience.dismissed", true) + ); + assert.equal(wrapper.getDOMNode().style["max-height"], "0px"); + assert.equal(wrapper.getDOMNode().style.opacity, "0"); + }); + + it("should update max-height on resize", () => { + sinon + .stub(wrapper.find(".ds-onboarding-ref").getDOMNode(), "offsetHeight") + .get(() => 123); + resizeCallback(); + assert.equal(wrapper.getDOMNode().style["max-height"], "123px"); + }); + + it("should fire intersection events", () => { + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "IMPRESSION", + source: "POCKET_ONBOARDING", + }) + ); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx new file mode 100644 index 0000000000..3721508a59 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx @@ -0,0 +1,134 @@ +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<CollectionCardGrid>", () => { + let wrapper; + let sandbox; + let dispatchStub; + const initialSpocs = [ + { id: 123, url: "123" }, + { id: 456, url: "456" }, + { id: 789, url: "789" }, + ]; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatchStub = sandbox.stub(); + wrapper = shallow( + <CollectionCardGrid + dispatch={dispatchStub} + type="COLLECTIONCARDGRID" + placement={{ + name: "spocs", + }} + data={{ + spocs: initialSpocs, + }} + spocs={{ + data: { + spocs: { + title: "title", + context: "context", + items: initialSpocs, + }, + }, + }} + /> + ); + }); + + it("should render an empty div", () => { + wrapper = shallow(<CollectionCardGrid />); + assert.ok(wrapper.exists()); + assert.ok(!wrapper.exists(".ds-collection-card-grid")); + }); + + it("should render a CardGrid", () => { + assert.lengthOf(wrapper.find(".ds-collection-card-grid").children(), 1); + assert.equal( + wrapper.find(".ds-collection-card-grid").children().at(0).type(), + CardGrid + ); + }); + + it("should inject spocs in every CardGrid rec position", () => { + assert.lengthOf( + wrapper.find(".ds-collection-card-grid").children().at(0).props().data + .recommendations, + 3 + ); + }); + + it("should pass along title and context to CardGrid", () => { + assert.equal( + wrapper.find(".ds-collection-card-grid").children().at(0).props().title, + "title" + ); + + assert.equal( + wrapper.find(".ds-collection-card-grid").children().at(0).props().context, + "context" + ); + }); + + it("should render nothing without a title", () => { + wrapper = shallow( + <CollectionCardGrid + dispatch={dispatchStub} + placement={{ + name: "spocs", + }} + data={{ + spocs: initialSpocs, + }} + spocs={{ + data: { + spocs: { + title: "", + context: "context", + items: initialSpocs, + }, + }, + }} + /> + ); + + assert.ok(wrapper.exists()); + assert.ok(!wrapper.exists(".ds-collection-card-grid")); + }); + + it("should dispatch telemety events on dismiss", () => { + wrapper.instance().onDismissClick(); + + const firstCall = dispatchStub.getCall(0); + const secondCall = dispatchStub.getCall(1); + const thirdCall = dispatchStub.getCall(2); + + assert.equal(firstCall.args[0].type, "BLOCK_URL"); + assert.deepEqual(firstCall.args[0].data, [ + { url: "123", pocket_id: undefined, isSponsoredTopSite: undefined }, + { url: "456", pocket_id: undefined, isSponsoredTopSite: undefined }, + { url: "789", pocket_id: undefined, isSponsoredTopSite: undefined }, + ]); + + assert.equal(secondCall.args[0].type, "DISCOVERY_STREAM_USER_EVENT"); + assert.deepEqual(secondCall.args[0].data, { + event: "BLOCK", + source: "COLLECTIONCARDGRID", + action_position: 0, + }); + + assert.equal(thirdCall.args[0].type, "TELEMETRY_IMPRESSION_STATS"); + assert.deepEqual(thirdCall.args[0].data, { + source: "COLLECTIONCARDGRID", + block: 0, + tiles: [ + { id: 123, pos: 0 }, + { id: 456, pos: 1 }, + { id: 789, pos: 2 }, + ], + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx new file mode 100644 index 0000000000..2ebba1d4e5 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSCard.test.jsx @@ -0,0 +1,544 @@ +import { + _DSCard as DSCard, + readTimeFromWordCount, + DSSource, + DefaultMeta, + PlaceholderDSCard, +} from "content-src/components/DiscoveryStreamComponents/DSCard/DSCard"; +import { + DSContextFooter, + StatusMessage, + SponsorLabel, +} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu"; +import React from "react"; +import { INITIAL_STATE } from "common/Reducers.sys.mjs"; +import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { shallow, mount } from "enzyme"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +const DEFAULT_PROPS = { + url: "about:robots", + title: "title", + App: { + isForStartupCache: false, + }, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, +}; + +describe("<DSCard>", () => { + let wrapper; + let sandbox; + let dispatch; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + wrapper = shallow(<DSCard dispatch={dispatch} {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-card")); + }); + + it("should render a SafeAnchor", () => { + wrapper.setProps({ url: "https://foo.com" }); + + assert.equal(wrapper.children().at(0).type(), SafeAnchor); + assert.propertyVal( + wrapper.children().at(0).props(), + "url", + "https://foo.com" + ); + }); + + it("should pass onLinkClick prop", () => { + assert.propertyVal( + wrapper.children().at(0).props(), + "onLinkClick", + wrapper.instance().onLinkClick + ); + }); + + it("should render DSLinkMenu", () => { + assert.equal(wrapper.children().at(1).type(), DSLinkMenu); + }); + + it("should start with no .active class", () => { + assert.equal(wrapper.find(".active").length, 0); + }); + + it("should render badges for pocket, bookmark when not a spoc element ", () => { + wrapper = mount(<DSCard context_type="bookmark" {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + const contextFooter = wrapper.find(DSContextFooter); + + assert.lengthOf(contextFooter.find(StatusMessage), 1); + }); + + it("should render Sponsored Context for a spoc element", () => { + const context = "Sponsored by Foo"; + wrapper = mount( + <DSCard context_type="bookmark" context={context} {...DEFAULT_PROPS} /> + ); + wrapper.setState({ isSeen: true }); + const contextFooter = wrapper.find(DSContextFooter); + + assert.lengthOf(contextFooter.find(StatusMessage), 0); + assert.equal(contextFooter.find(".story-sponsored-label").text(), context); + }); + + it("should render time to read", () => { + const discoveryStream = { + ...INITIAL_STATE.DiscoveryStream, + readTime: true, + }; + wrapper = mount( + <DSCard + time_to_read={4} + {...DEFAULT_PROPS} + DiscoveryStream={discoveryStream} + /> + ); + wrapper.setState({ isSeen: true }); + const defaultMeta = wrapper.find(DefaultMeta); + assert.lengthOf(defaultMeta, 1); + assert.equal(defaultMeta.props().timeToRead, 4); + }); + + it("should not show save to pocket button for spocs", () => { + wrapper.setProps({ + id: "fooidx", + pos: 1, + type: "foo", + flightId: 12345, + saveToPocketCard: true, + }); + + let stpButton = wrapper.find(".card-stp-button"); + + assert.lengthOf(stpButton, 0); + }); + + it("should show save to pocket button for non-spocs", () => { + wrapper.setProps({ + id: "fooidx", + pos: 1, + type: "foo", + saveToPocketCard: true, + }); + + let stpButton = wrapper.find(".card-stp-button"); + + assert.lengthOf(stpButton, 1); + }); + + describe("onLinkClick", () => { + let fakeWindow; + + beforeEach(() => { + fakeWindow = { + requestIdleCallback: sinon.stub().returns(1), + cancelIdleCallback: sinon.stub(), + innerWidth: 1000, + innerHeight: 900, + }; + wrapper = mount( + <DSCard {...DEFAULT_PROPS} dispatch={dispatch} windowObj={fakeWindow} /> + ); + }); + + it("should call dispatch with the correct events", () => { + wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" }); + + wrapper.instance().onLinkClick(); + + assert.calledTwice(dispatch); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "FOO", + action_position: 1, + value: { card_type: "organic" }, + }) + ); + assert.calledWith( + dispatch, + ac.ImpressionStats({ + click: 0, + source: "FOO", + tiles: [{ id: "fooidx", pos: 1, type: "organic" }], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + + it("should set the right card_type on spocs", () => { + wrapper.setProps({ id: "fooidx", pos: 1, type: "foo", flightId: 12345 }); + + wrapper.instance().onLinkClick(); + + assert.calledTwice(dispatch); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "FOO", + action_position: 1, + value: { card_type: "spoc" }, + }) + ); + assert.calledWith( + dispatch, + ac.ImpressionStats({ + click: 0, + source: "FOO", + tiles: [{ id: "fooidx", pos: 1, type: "spoc" }], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + + it("should call dispatch with a shim", () => { + wrapper.setProps({ + id: "fooidx", + pos: 1, + type: "foo", + shim: { + click: "click shim", + }, + }); + + wrapper.instance().onLinkClick(); + + assert.calledTwice(dispatch); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "FOO", + action_position: 1, + value: { card_type: "organic" }, + }) + ); + assert.calledWith( + dispatch, + ac.ImpressionStats({ + click: 0, + source: "FOO", + tiles: [ + { id: "fooidx", pos: 1, shim: "click shim", type: "organic" }, + ], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + }); + + describe("DSCard with CTA", () => { + beforeEach(() => { + wrapper = mount(<DSCard {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + }); + + it("should render Default Meta", () => { + const default_meta = wrapper.find(DefaultMeta); + assert.ok(default_meta.exists()); + }); + }); + + describe("DSCard with Intersection Observer", () => { + beforeEach(() => { + wrapper = shallow(<DSCard {...DEFAULT_PROPS} />); + }); + + it("should render card when seen", () => { + let card = wrapper.find("div.ds-card.placeholder"); + assert.lengthOf(card, 1); + + wrapper.instance().observer = { + unobserve: sandbox.stub(), + }; + wrapper.instance().placeholderElement = "element"; + + wrapper.instance().onSeen([ + { + isIntersecting: true, + }, + ]); + + assert.isTrue(wrapper.instance().state.isSeen); + card = wrapper.find("div.ds-card.placeholder"); + assert.lengthOf(card, 0); + assert.lengthOf(wrapper.find(SafeAnchor), 1); + assert.calledOnce(wrapper.instance().observer.unobserve); + assert.calledWith(wrapper.instance().observer.unobserve, "element"); + }); + + it("should setup proper placholder ref for isSeen", () => { + wrapper.instance().setPlaceholderRef("element"); + assert.equal(wrapper.instance().placeholderElement, "element"); + }); + + it("should setup observer on componentDidMount", () => { + wrapper = mount(<DSCard {...DEFAULT_PROPS} />); + assert.isTrue(!!wrapper.instance().observer); + }); + }); + + describe("DSCard with Idle Callback", () => { + let windowStub = { + requestIdleCallback: sinon.stub().returns(1), + cancelIdleCallback: sinon.stub(), + }; + beforeEach(() => { + wrapper = shallow(<DSCard windowObj={windowStub} {...DEFAULT_PROPS} />); + }); + + it("should call requestIdleCallback on componentDidMount", () => { + assert.calledOnce(windowStub.requestIdleCallback); + }); + + it("should call cancelIdleCallback on componentWillUnmount", () => { + wrapper.instance().componentWillUnmount(); + assert.calledOnce(windowStub.cancelIdleCallback); + }); + }); + + describe("DSCard when rendered for about:home startup cache", () => { + beforeEach(() => { + const props = { + App: { + isForStartupCache: true, + }, + DiscoveryStream: INITIAL_STATE.DiscoveryStream, + }; + wrapper = mount(<DSCard {...props} />); + }); + + it("should be set as isSeen automatically", () => { + assert.isTrue(wrapper.instance().state.isSeen); + }); + }); + + describe("DSCard onSaveClick", () => { + it("should fire telemetry for onSaveClick", () => { + wrapper.setProps({ id: "fooidx", pos: 1, type: "foo" }); + wrapper.instance().onSaveClick(); + + assert.calledThrice(dispatch); + assert.calledWith( + dispatch, + ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { site: { url: "about:robots", title: "title" } }, + }) + ); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + source: "CARDGRID_HOVER", + action_position: 1, + value: { card_type: "organic" }, + }) + ); + assert.calledWith( + dispatch, + ac.ImpressionStats({ + source: "CARDGRID_HOVER", + pocket: 0, + tiles: [ + { + id: "fooidx", + pos: 1, + }, + ], + }) + ); + }); + }); + + describe("DSCard menu open states", () => { + let cardNode; + let fakeDocument; + let fakeWindow; + + beforeEach(() => { + fakeDocument = { l10n: { translateFragment: sinon.stub() } }; + fakeWindow = { + document: fakeDocument, + requestIdleCallback: sinon.stub().returns(1), + cancelIdleCallback: sinon.stub(), + }; + wrapper = mount(<DSCard {...DEFAULT_PROPS} windowObj={fakeWindow} />); + wrapper.setState({ isSeen: true }); + cardNode = wrapper.getDOMNode(); + }); + + it("Should remove active on Menu Update", () => { + // Add active class name to DSCard wrapper + // to simulate menu open state + cardNode.classList.add("active"); + assert.equal( + cardNode.className, + "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 active" + ); + + wrapper.instance().onMenuUpdate(false); + wrapper.update(); + + assert.equal( + cardNode.className, + "ds-card ds-card-title-lines-3 ds-card-desc-lines-3" + ); + }); + + it("Should add active on Menu Show", async () => { + await wrapper.instance().onMenuShow(); + wrapper.update(); + assert.equal( + cardNode.className, + "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 active" + ); + }); + + it("Should add last-item to support resized window", async () => { + fakeWindow.scrollMaxX = 20; + await wrapper.instance().onMenuShow(); + wrapper.update(); + assert.equal( + cardNode.className, + "ds-card ds-card-title-lines-3 ds-card-desc-lines-3 last-item active" + ); + }); + + it("should remove .active and .last-item classes", () => { + const instance = wrapper.instance(); + const remove = sinon.stub(); + instance.contextMenuButtonHostElement = { + classList: { remove }, + }; + instance.onMenuUpdate(); + assert.calledOnce(remove); + }); + + it("should add .active and .last-item classes", async () => { + const instance = wrapper.instance(); + const add = sinon.stub(); + instance.contextMenuButtonHostElement = { + classList: { add }, + }; + await instance.onMenuShow(); + assert.calledOnce(add); + }); + }); +}); + +describe("<PlaceholderDSCard> component", () => { + it("should have placeholder prop", () => { + const wrapper = shallow(<PlaceholderDSCard />); + const placeholder = wrapper.prop("placeholder"); + assert.isTrue(placeholder); + }); + + it("should contain placeholder div", () => { + const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + const card = wrapper.find("div.ds-card.placeholder"); + assert.lengthOf(card, 1); + }); + + it("should not be clickable", () => { + const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + const anchor = wrapper.find("SafeAnchor.ds-card-link"); + assert.lengthOf(anchor, 0); + }); + + it("should not have context menu", () => { + const wrapper = shallow(<DSCard placeholder={true} {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + const linkMenu = wrapper.find(DSLinkMenu); + assert.lengthOf(linkMenu, 0); + }); +}); + +describe("<DSSource> component", () => { + it("should return a default source without compact", () => { + const wrapper = shallow(<DSSource source="Mozilla" />); + + let sourceElement = wrapper.find(".source"); + assert.equal(sourceElement.text(), "Mozilla"); + }); + it("should return a default source with compact without a sponsor or time to read", () => { + const wrapper = shallow(<DSSource compact={true} source="Mozilla" />); + + let sourceElement = wrapper.find(".source"); + assert.equal(sourceElement.text(), "Mozilla"); + }); + it("should return a SponsorLabel with compact and a sponsor", () => { + const wrapper = shallow( + <DSSource newSponsoredLabel={true} sponsor="Mozilla" /> + ); + const sponsorLabel = wrapper.find(SponsorLabel); + assert.lengthOf(sponsorLabel, 1); + }); + it("should return a time to read with compact and without a sponsor but with a time to read", () => { + const wrapper = shallow( + <DSSource compact={true} source="Mozilla" timeToRead="2000" /> + ); + + let timeToRead = wrapper.find(".time-to-read"); + assert.lengthOf(timeToRead, 1); + + // Weirdly, we can test for the pressence of fluent, because time to read needs to be translated. + // This is also because we did a shallow render, that th contents of fluent would be empty anyway. + const fluentOrText = wrapper.find(FluentOrText); + assert.lengthOf(fluentOrText, 1); + }); + it("should prioritize a SponsorLabel if for some reason it gets everything", () => { + const wrapper = shallow( + <DSSource + newSponsoredLabel={true} + sponsor="Mozilla" + source="Mozilla" + timeToRead="2000" + /> + ); + const sponsorLabel = wrapper.find(SponsorLabel); + assert.lengthOf(sponsorLabel, 1); + }); +}); + +describe("readTimeFromWordCount function", () => { + it("should return proper read time", () => { + const result = readTimeFromWordCount(2000); + assert.equal(result, 10); + }); + it("should return false with falsey word count", () => { + assert.isFalse(readTimeFromWordCount()); + assert.isFalse(readTimeFromWordCount(0)); + assert.isFalse(readTimeFromWordCount("")); + assert.isFalse(readTimeFromWordCount(null)); + assert.isFalse(readTimeFromWordCount(undefined)); + }); + it("should return NaN with invalid word count", () => { + assert.isNaN(readTimeFromWordCount("zero")); + assert.isNaN(readTimeFromWordCount({})); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx new file mode 100644 index 0000000000..08ac7868ce --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSContextFooter.test.jsx @@ -0,0 +1,138 @@ +import { + DSContextFooter, + StatusMessage, + DSMessageFooter, +} from "content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter"; +import React from "react"; +import { mount } from "enzyme"; +import { cardContextTypes } from "content-src/components/Card/types.js"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText.jsx"; + +describe("<DSContextFooter>", () => { + let wrapper; + let sandbox; + const bookmarkBadge = "bookmark"; + const removeBookmarkBadge = "removedBookmark"; + const context = "Sponsored by Babel"; + const sponsored_by_override = "Sponsored override"; + const engagement = "Popular"; + + beforeEach(() => { + wrapper = mount(<DSContextFooter />); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => assert.isTrue(wrapper.exists())); + it("should not render an engagement status if display_engagement_labels is false", () => { + wrapper = mount( + <DSContextFooter + display_engagement_labels={false} + engagement={engagement} + /> + ); + + const engagementLabel = wrapper.find(".story-view-count"); + assert.equal(engagementLabel.length, 0); + }); + it("should render a badge if a proper badge prop is passed", () => { + wrapper = mount( + <DSContextFooter context_type={bookmarkBadge} engagement={engagement} /> + ); + const { fluentID } = cardContextTypes[bookmarkBadge]; + + assert.lengthOf(wrapper.find(".story-view-count"), 0); + const statusLabel = wrapper.find(".story-context-label"); + assert.equal(statusLabel.prop("data-l10n-id"), fluentID); + }); + it("should only render a sponsored context if pass a sponsored context", async () => { + wrapper = mount( + <DSContextFooter + context_type={bookmarkBadge} + context={context} + engagement={engagement} + /> + ); + + assert.lengthOf(wrapper.find(".story-view-count"), 0); + assert.lengthOf(wrapper.find(StatusMessage), 0); + assert.equal(wrapper.find(".story-sponsored-label").text(), context); + }); + it("should render a sponsored_by_override if passed a sponsored_by_override", async () => { + wrapper = mount( + <DSContextFooter + context_type={bookmarkBadge} + context={context} + sponsored_by_override={sponsored_by_override} + engagement={engagement} + /> + ); + + assert.equal( + wrapper.find(".story-sponsored-label").text(), + sponsored_by_override + ); + }); + it("should render nothing with a sponsored_by_override empty string", async () => { + wrapper = mount( + <DSContextFooter + context_type={bookmarkBadge} + context={context} + sponsored_by_override="" + engagement={engagement} + /> + ); + + assert.isFalse(wrapper.find(".story-sponsored-label").exists()); + }); + it("should render localized string with sponsor with no sponsored_by_override", async () => { + wrapper = mount( + <DSContextFooter + context_type={bookmarkBadge} + context={context} + sponsor="Nimoy" + engagement={engagement} + /> + ); + + assert.equal( + wrapper.find(".story-sponsored-label").children().at(0).type(), + FluentOrText + ); + }); + it("should render a new badge if props change from an old badge to a new one", async () => { + wrapper = mount(<DSContextFooter context_type={bookmarkBadge} />); + + const { fluentID: bookmarkFluentID } = cardContextTypes[bookmarkBadge]; + const bookmarkStatusMessage = wrapper.find( + `div[data-l10n-id='${bookmarkFluentID}']` + ); + assert.isTrue(bookmarkStatusMessage.exists()); + + const { fluentID: removeBookmarkFluentID } = + cardContextTypes[removeBookmarkBadge]; + + wrapper.setProps({ context_type: removeBookmarkBadge }); + await wrapper.update(); + + assert.isEmpty(bookmarkStatusMessage); + const removedBookmarkStatusMessage = wrapper.find( + `div[data-l10n-id='${removeBookmarkFluentID}']` + ); + assert.isTrue(removedBookmarkStatusMessage.exists()); + }); + it("should render a story footer", () => { + wrapper = mount( + <DSMessageFooter + context_type={bookmarkBadge} + engagement={engagement} + display_engagement_labels={true} + /> + ); + + assert.lengthOf(wrapper.find(".story-footer"), 1); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx new file mode 100644 index 0000000000..2f7e206b4f --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSDismiss.test.jsx @@ -0,0 +1,51 @@ +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<DSDismiss>", () => { + const fakeSpoc = { + url: "https://foo.com", + guid: "1234", + }; + let wrapper; + let sandbox; + let onDismissClickStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + onDismissClickStub = sandbox.stub(); + wrapper = shallow( + <DSDismiss + data={fakeSpoc} + onDismissClick={onDismissClickStub} + shouldSendImpressionStats={true} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-dismiss").exists()); + }); + + it("should render proper hover state", () => { + wrapper.instance().onHover(); + assert.ok(wrapper.find(".hovering").exists()); + wrapper.instance().offHover(); + assert.ok(!wrapper.find(".hovering").exists()); + }); + + it("should dispatch call onDismissClick", () => { + wrapper.instance().onDismissClick(); + assert.calledOnce(onDismissClickStub); + }); + + it("should add extra classes", () => { + wrapper = shallow(<DSDismiss extraClasses="extra-class" />); + assert.ok(wrapper.find(".extra-class").exists()); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx new file mode 100644 index 0000000000..6aa8045299 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx @@ -0,0 +1,73 @@ +import { DSEmptyState } from "content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<DSEmptyState>", () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(<DSEmptyState />); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".section-empty-state").exists()); + }); + + it("should render defaultempty state message", () => { + assert.ok(wrapper.find(".empty-state-message").exists()); + const header = wrapper.find( + "h2[data-l10n-id='newtab-discovery-empty-section-topstories-header']" + ); + const paragraph = wrapper.find( + "p[data-l10n-id='newtab-discovery-empty-section-topstories-content']" + ); + + assert.ok(header.exists()); + assert.ok(paragraph.exists()); + }); + + it("should render failed state message", () => { + wrapper = shallow(<DSEmptyState status="failed" />); + const button = wrapper.find( + "button[data-l10n-id='newtab-discovery-empty-section-topstories-try-again-button']" + ); + + assert.ok(button.exists()); + }); + + it("should render waiting state message", () => { + wrapper = shallow(<DSEmptyState status="waiting" />); + const button = wrapper.find( + "button[data-l10n-id='newtab-discovery-empty-section-topstories-loading']" + ); + + assert.ok(button.exists()); + }); + + it("should dispatch DISCOVERY_STREAM_RETRY_FEED on failed state button click", () => { + const dispatch = sinon.spy(); + + wrapper = shallow( + <DSEmptyState + status="failed" + dispatch={dispatch} + feed={{ url: "https://foo.com", data: {} }} + /> + ); + wrapper.find("button.try-again-button").simulate("click"); + + assert.calledTwice(dispatch); + let [action] = dispatch.firstCall.args; + assert.equal(action.type, "DISCOVERY_STREAM_FEED_UPDATE"); + assert.deepEqual(action.data.feed, { + url: "https://foo.com", + data: { status: "waiting" }, + }); + + [action] = dispatch.secondCall.args; + + assert.equal(action.type, "DISCOVERY_STREAM_RETRY_FEED"); + assert.deepEqual(action.data.feed, { url: "https://foo.com", data: {} }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx new file mode 100644 index 0000000000..bb2ce3b0b3 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSImage.test.jsx @@ -0,0 +1,146 @@ +import { DSImage } from "content-src/components/DiscoveryStreamComponents/DSImage/DSImage"; +import { mount } from "enzyme"; +import React from "react"; + +describe("Discovery Stream <DSImage>", () => { + it("should have a child with class ds-image", () => { + const img = mount(<DSImage />); + const child = img.find(".ds-image"); + + assert.lengthOf(child, 1); + }); + + it("should set proper sources if only `source` is available", () => { + const img = mount(<DSImage source="https://placekitten.com/g/640/480" />); + + assert.equal( + img.find("img").prop("src"), + "https://placekitten.com/g/640/480" + ); + }); + + it("should set proper sources if `rawSource` is available", () => { + const testSizes = [ + { + mediaMatcher: "(min-width: 1122px)", + width: 296, + height: 148, + }, + + { + mediaMatcher: "(min-width: 866px)", + width: 218, + height: 109, + }, + + { + mediaMatcher: "(max-width: 610px)", + width: 202, + height: 101, + }, + ]; + + const img = mount( + <DSImage + rawSource="https://placekitten.com/g/640/480" + sizes={testSizes} + /> + ); + + assert.equal( + img.find("img").prop("src"), + "https://placekitten.com/g/640/480" + ); + assert.equal( + img.find("img").prop("srcSet"), + [ + "https://img-getpocket.cdn.mozilla.net/296x148/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 296w", + "https://img-getpocket.cdn.mozilla.net/592x296/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 592w", + "https://img-getpocket.cdn.mozilla.net/218x109/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 218w", + "https://img-getpocket.cdn.mozilla.net/436x218/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 436w", + "https://img-getpocket.cdn.mozilla.net/202x101/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 202w", + "https://img-getpocket.cdn.mozilla.net/404x202/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fplacekitten.com%2Fg%2F640%2F480 404w", + ].join(",") + ); + }); + + it("should fall back to unoptimized when optimized failed", () => { + const img = mount( + <DSImage + source="https://placekitten.com/g/640/480" + rawSource="https://placekitten.com/g/640/480" + /> + ); + img.setState({ + isSeen: true, + containerWidth: 640, + containerHeight: 480, + }); + + img.instance().onOptimizedImageError(); + img.update(); + + assert.equal( + img.find("img").prop("src"), + "https://placekitten.com/g/640/480" + ); + }); + + it("should render a placeholder image with no source and recent save", () => { + const img = mount(<DSImage isRecentSave={true} url="foo" title="bar" />); + img.setState({ isSeen: true }); + + img.update(); + + assert.equal(img.find("div").prop("className"), "placeholder-image"); + }); + + it("should render a broken image with a source and a recent save", () => { + const img = mount(<DSImage isRecentSave={true} source="foo" />); + img.setState({ isSeen: true }); + + img.instance().onNonOptimizedImageError(); + img.update(); + + assert.equal(img.find("div").prop("className"), "broken-image"); + }); + + it("should render a broken image without a source and not a recent save", () => { + const img = mount(<DSImage isRecentSave={false} />); + img.setState({ isSeen: true }); + + img.instance().onNonOptimizedImageError(); + img.update(); + + assert.equal(img.find("div").prop("className"), "broken-image"); + }); + + it("should update loaded state when seen", () => { + const img = mount( + <DSImage rawSource="https://placekitten.com/g/640/480" /> + ); + + img.instance().onLoad(); + assert.propertyVal(img.state(), "isLoaded", true); + }); + + describe("DSImage with Idle Callback", () => { + let wrapper; + let windowStub = { + requestIdleCallback: sinon.stub().returns(1), + cancelIdleCallback: sinon.stub(), + }; + beforeEach(() => { + wrapper = mount(<DSImage windowObj={windowStub} />); + }); + + it("should call requestIdleCallback on componentDidMount", () => { + assert.calledOnce(windowStub.requestIdleCallback); + }); + + it("should call cancelIdleCallback on componentWillUnmount", () => { + wrapper.instance().componentWillUnmount(); + assert.calledOnce(windowStub.cancelIdleCallback); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx new file mode 100644 index 0000000000..3aa128a32a --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSLinkMenu.test.jsx @@ -0,0 +1,151 @@ +import { mount, shallow } from "enzyme"; +import { DSLinkMenu } from "content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; + +describe("<DSLinkMenu>", () => { + let wrapper; + + describe("DS link menu actions", () => { + beforeEach(() => { + wrapper = mount(<DSLinkMenu />); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it("should parse args for fluent correctly ", () => { + const title = '"fluent"'; + wrapper = mount(<DSLinkMenu title={title} />); + + const button = wrapper.find( + "button[data-l10n-id='newtab-menu-content-tooltip']" + ); + assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title })); + }); + }); + + describe("DS context menu options", () => { + const ValidDSLinkMenuProps = { + site: {}, + pocket_button_enabled: true, + }; + + beforeEach(() => { + wrapper = shallow(<DSLinkMenu {...ValidDSLinkMenuProps} />); + }); + + it("should render a context menu button", () => { + assert.ok(wrapper.exists()); + assert.ok( + wrapper.find(ContextMenuButton).exists(), + "context menu button exists" + ); + }); + + it("should render LinkMenu when context menu button is clicked", () => { + let button = wrapper.find(ContextMenuButton); + button.simulate("click", { preventDefault: () => {} }); + assert.equal(wrapper.find(LinkMenu).length, 1); + }); + + it("should pass dispatch, onShow, site, options, shouldSendImpressionStats, source and index to LinkMenu", () => { + wrapper + .find(ContextMenuButton) + .simulate("click", { preventDefault: () => {} }); + const linkMenuProps = wrapper.find(LinkMenu).props(); + [ + "dispatch", + "onShow", + "site", + "index", + "options", + "source", + "shouldSendImpressionStats", + ].forEach(prop => assert.property(linkMenuProps, prop)); + }); + + it("should pass through the correct menu options to LinkMenu", () => { + wrapper + .find(ContextMenuButton) + .simulate("click", { preventDefault: () => {} }); + const linkMenuProps = wrapper.find(LinkMenu).props(); + assert.deepEqual(linkMenuProps.options, [ + "CheckBookmark", + "CheckArchiveFromPocket", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ]); + }); + + it("should pass through the correct menu options to LinkMenu for spocs", () => { + wrapper = shallow( + <DSLinkMenu + {...ValidDSLinkMenuProps} + flightId="1234" + showPrivacyInfo={true} + /> + ); + wrapper + .find(ContextMenuButton) + .simulate("click", { preventDefault: () => {} }); + const linkMenuProps = wrapper.find(LinkMenu).props(); + assert.deepEqual(linkMenuProps.options, [ + "CheckBookmark", + "CheckArchiveFromPocket", + "CheckSavedToPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", + ]); + }); + + it("should pass through the correct menu options to LinkMenu for save to Pocket button", () => { + wrapper = shallow( + <DSLinkMenu {...ValidDSLinkMenuProps} saveToPocketCard={true} /> + ); + wrapper + .find(ContextMenuButton) + .simulate("click", { preventDefault: () => {} }); + const linkMenuProps = wrapper.find(LinkMenu).props(); + assert.deepEqual(linkMenuProps.options, [ + "CheckBookmark", + "CheckArchiveFromPocket", + "CheckDeleteFromPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ]); + }); + + it("should pass through the correct menu options to LinkMenu if Pocket is disabled", () => { + wrapper = shallow( + <DSLinkMenu {...ValidDSLinkMenuProps} pocket_button_enabled={false} /> + ); + wrapper + .find(ContextMenuButton) + .simulate("click", { preventDefault: () => {} }); + const linkMenuProps = wrapper.find(LinkMenu).props(); + assert.deepEqual(linkMenuProps.options, [ + "CheckBookmark", + "CheckArchiveFromPocket", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ]); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx new file mode 100644 index 0000000000..7d9f13cc8a --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSMessage.test.jsx @@ -0,0 +1,57 @@ +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import React from "react"; +import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { mount } from "enzyme"; + +describe("<DSMessage>", () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(<DSMessage />); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-message").exists()); + }); + + it("should render an icon", () => { + wrapper.setProps({ icon: "foo" }); + + assert.ok(wrapper.find(".glyph").exists()); + assert.propertyVal( + wrapper.find(".glyph").props().style, + "backgroundImage", + `url(foo)` + ); + }); + + it("should render a title", () => { + wrapper.setProps({ title: "foo" }); + + assert.ok(wrapper.find(".title-text").exists()); + assert.equal(wrapper.find(".title-text").text(), "foo"); + }); + + it("should render a SafeAnchor", () => { + wrapper.setProps({ link_text: "foo", link_url: "https://foo.com" }); + + assert.equal(wrapper.find(".title").children().at(0).type(), SafeAnchor); + }); + + it("should render a FluentOrText", () => { + wrapper.setProps({ + link_text: "link_text", + title: "title", + link_url: "https://link_url.com", + }); + + assert.equal( + wrapper.find(".title-text").children().at(0).type(), + FluentOrText + ); + + assert.equal(wrapper.find(".link a").children().at(0).type(), FluentOrText); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx new file mode 100644 index 0000000000..b4b743c7ff --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSPrivacyModal.test.jsx @@ -0,0 +1,50 @@ +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { shallow, mount } from "enzyme"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +describe("Discovery Stream <DSPrivacyModal>", () => { + let sandbox; + let dispatch; + let wrapper; + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + wrapper = shallow(<DSPrivacyModal dispatch={dispatch} />); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should contain a privacy notice", () => { + const modal = mount(<DSPrivacyModal />); + const child = modal.find(".privacy-notice"); + + assert.lengthOf(child, 1); + }); + + it("should call dispatch when modal is closed", () => { + wrapper.instance().closeModal(); + assert.calledOnce(dispatch); + }); + + it("should call dispatch with the correct events for onLearnLinkClick", () => { + wrapper.instance().onLearnLinkClick(); + + assert.calledOnce(dispatch); + assert.calledWith( + dispatch, + ac.DiscoveryStreamUserEvent({ + event: "CLICK_PRIVACY_INFO", + source: "DS_PRIVACY_MODAL", + }) + ); + }); + + it("should call dispatch with the correct events for onManageLinkClick", () => { + wrapper.instance().onManageLinkClick(); + + assert.calledOnce(dispatch); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx new file mode 100644 index 0000000000..904f98e439 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSSignup.test.jsx @@ -0,0 +1,92 @@ +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<DSSignup>", () => { + let wrapper; + let sandbox; + let dispatchStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatchStub = sandbox.stub(); + wrapper = shallow( + <DSSignup + data={{ + spocs: [ + { + shim: { impression: "1234" }, + id: "1234", + }, + ], + }} + type="SIGNUP" + dispatch={dispatchStub} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-signup").exists()); + }); + + it("should dispatch a click event on click", () => { + wrapper.instance().onLinkClick(); + + assert.calledTwice(dispatchStub); + assert.deepEqual(dispatchStub.firstCall.args[0].data, { + event: "CLICK", + source: "SIGNUP", + action_position: 0, + }); + assert.deepEqual(dispatchStub.secondCall.args[0].data, { + source: "SIGNUP", + click: 0, + tiles: [{ id: "1234", pos: 0 }], + }); + }); + + it("Should remove active on Menu Update", () => { + wrapper.setState = sandbox.stub(); + wrapper.instance().onMenuButtonUpdate(false); + assert.calledWith(wrapper.setState, { active: false, lastItem: false }); + }); + + it("Should add active on Menu Show", async () => { + wrapper.setState = sandbox.stub(); + wrapper.instance().nextAnimationFrame = () => {}; + await wrapper.instance().onMenuShow(); + assert.calledWith(wrapper.setState, { active: true, lastItem: false }); + }); + + it("Should add last-item to support resized window", async () => { + const fakeWindow = { scrollMaxX: "20" }; + wrapper = shallow(<DSSignup windowObj={fakeWindow} />); + wrapper.setState = sandbox.stub(); + wrapper.instance().nextAnimationFrame = () => {}; + await wrapper.instance().onMenuShow(); + assert.calledWith(wrapper.setState, { active: true, lastItem: true }); + }); + + it("Should add last-item and active classes", () => { + wrapper.setState({ + active: true, + lastItem: true, + }); + assert.ok(wrapper.find(".last-item").exists()); + assert.ok(wrapper.find(".active").exists()); + }); + + it("Should call rAF from nextAnimationFrame", () => { + const fakeWindow = { requestAnimationFrame: sinon.stub() }; + wrapper = shallow(<DSSignup windowObj={fakeWindow} />); + + wrapper.instance().nextAnimationFrame(); + assert.calledOnce(fakeWindow.requestAnimationFrame); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx new file mode 100644 index 0000000000..1888e194af --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx @@ -0,0 +1,94 @@ +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<DSTextPromo>", () => { + let wrapper; + let sandbox; + let dispatchStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatchStub = sandbox.stub(); + wrapper = shallow( + <DSTextPromo + data={{ + spocs: [ + { + shim: { impression: "1234" }, + id: "1234", + }, + ], + }} + type="TEXTPROMO" + dispatch={dispatchStub} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-text-promo").exists()); + }); + + it("should render a header", () => { + wrapper.setProps({ header: "foo" }); + assert.ok(wrapper.find(".text").exists()); + }); + + it("should render a subtitle", () => { + wrapper.setProps({ subtitle: "foo" }); + assert.ok(wrapper.find(".subtitle").exists()); + }); + + it("should dispatch a click event on click", () => { + wrapper.instance().onLinkClick(); + + assert.calledTwice(dispatchStub); + assert.deepEqual(dispatchStub.firstCall.args[0].data, { + event: "CLICK", + source: "TEXTPROMO", + action_position: 0, + }); + assert.deepEqual(dispatchStub.secondCall.args[0].data, { + source: "TEXTPROMO", + click: 0, + tiles: [{ id: "1234", pos: 0 }], + }); + }); + + it("should dispath telemety events on dismiss", () => { + wrapper.instance().onDismissClick(); + + const firstCall = dispatchStub.getCall(0); + const secondCall = dispatchStub.getCall(1); + const thirdCall = dispatchStub.getCall(2); + + assert.equal(firstCall.args[0].type, "BLOCK_URL"); + assert.deepEqual(firstCall.args[0].data, [ + { + url: undefined, + pocket_id: undefined, + isSponsoredTopSite: undefined, + }, + ]); + + assert.equal(secondCall.args[0].type, "DISCOVERY_STREAM_USER_EVENT"); + assert.deepEqual(secondCall.args[0].data, { + event: "BLOCK", + source: "TEXTPROMO", + action_position: 0, + }); + + assert.equal(thirdCall.args[0].type, "TELEMETRY_IMPRESSION_STATS"); + assert.deepEqual(thirdCall.args[0].data, { + source: "TEXTPROMO", + block: 0, + tiles: [{ id: "1234", pos: 0 }], + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx new file mode 100644 index 0000000000..d8c16d8e71 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Highlights.test.jsx @@ -0,0 +1,41 @@ +import { combineReducers, createStore } from "redux"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { mount } from "enzyme"; +import { Provider } from "react-redux"; +import React from "react"; + +describe("Discovery Stream <Highlights>", () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + it("should render nothing with no highlights data", () => { + const store = createStore(combineReducers(reducers), { ...INITIAL_STATE }); + + wrapper = mount( + <Provider store={store}> + <Highlights /> + </Provider> + ); + + assert.ok(wrapper.isEmptyRender()); + }); + + it("should render highlights", () => { + const store = createStore(combineReducers(reducers), { + ...INITIAL_STATE, + Sections: [{ id: "highlights", enabled: true }], + }); + + wrapper = mount( + <Provider store={store}> + <Highlights /> + </Provider> + ); + + assert.lengthOf(wrapper.find(".ds-highlights"), 1); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx new file mode 100644 index 0000000000..03538df6f2 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/HorizontalRule.test.jsx @@ -0,0 +1,16 @@ +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<HorizontalRule>", () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(<HorizontalRule />); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-hr").exists()); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx new file mode 100644 index 0000000000..1d4778e342 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx @@ -0,0 +1,278 @@ +"use strict"; + +import { + ImpressionStats, + INTERSECTION_RATIO, +} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<ImpressionStats>", () => { + const SOURCE = "TEST_SOURCE"; + const FullIntersectEntries = [ + { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO }, + ]; + const ZeroIntersectEntries = [ + { isIntersecting: false, intersectionRatio: 0 }, + ]; + const PartialIntersectEntries = [ + { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 }, + ]; + + // Build IntersectionObserver class with the arg `entries` for the intersect callback. + function buildIntersectionObserver(entries) { + return class { + constructor(callback) { + this.callback = callback; + } + + observe() { + this.callback(entries); + } + + unobserve() {} + }; + } + + const DEFAULT_PROPS = { + rows: [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + { id: 3, pos: 2 }, + ], + source: SOURCE, + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + document: { + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }, + }; + + const InnerEl = () => <div>Inner Element</div>; + + function renderImpressionStats(props = {}) { + return shallow( + <ImpressionStats {...DEFAULT_PROPS} {...props}> + <InnerEl /> + </ImpressionStats> + ); + } + + it("should render props.children", () => { + const wrapper = renderImpressionStats(); + assert.ok(wrapper.contains(<InnerEl />)); + }); + it("should not send loaded content nor impression when the page is not visible", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }; + renderImpressionStats(props); + + assert.notCalled(dispatch); + }); + it("should noly send loaded content but not impression when the wrapped item is not visbible", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries), + }; + renderImpressionStats(props); + + // This one is for loaded content. + assert.calledOnce(dispatch); + const [action] = dispatch.firstCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); + assert.equal(action.data.source, SOURCE); + assert.deepEqual(action.data.tiles, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + { id: 3, pos: 2 }, + ]); + }); + it("should not send impression when the wrapped item is visbible but below the ratio", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries), + }; + renderImpressionStats(props); + + // This one is for loaded content. + assert.calledOnce(dispatch); + }); + it("should send a loaded content and an impression when the page is visible and the wrapped item meets the visibility ratio", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + }; + renderImpressionStats(props); + + assert.calledTwice(dispatch); + + let [action] = dispatch.firstCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); + assert.equal(action.data.source, SOURCE); + assert.deepEqual(action.data.tiles, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + { id: 3, pos: 2 }, + ]); + + [action] = dispatch.secondCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); + assert.equal(action.data.source, SOURCE); + assert.deepEqual(action.data.tiles, [ + { id: 1, pos: 0, type: "organic" }, + { id: 2, pos: 1, type: "organic" }, + { id: 3, pos: 2, type: "organic" }, + ]); + }); + it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => { + const dispatch = sinon.spy(); + const flightId = "a_flight_id"; + const props = { + dispatch, + flightId, + rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }], + source: "TOP_SITES", + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + }; + renderImpressionStats(props); + + // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression + assert.callCount(dispatch, 4); + + const [action] = dispatch.secondCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION); + assert.deepEqual(action.data, { flightId }); + }); + it("should send a TOP_SITES_SPONSORED_IMPRESSION_STATS when the wrapped item has a flightId", () => { + const dispatch = sinon.spy(); + const flightId = "a_flight_id"; + const props = { + dispatch, + flightId, + rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }], + source: "TOP_SITES", + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + }; + renderImpressionStats(props); + + // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression + assert.callCount(dispatch, 4); + + const [action] = dispatch.getCall(2).args; + assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); + assert.deepEqual(action.data, { + type: "impression", + tile_id: 1, + source: "newtab", + advertiser: "test advertiser", + position: 1, + }); + }); + it("should send an impression when the wrapped item transiting from invisible to visible", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries), + }; + const wrapper = renderImpressionStats(props); + + // For the loaded content + assert.calledOnce(dispatch); + + let [action] = dispatch.firstCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT); + assert.equal(action.data.source, SOURCE); + assert.deepEqual(action.data.tiles, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + { id: 3, pos: 2 }, + ]); + + dispatch.resetHistory(); + wrapper.instance().impressionObserver.callback(FullIntersectEntries); + + // For the impression + assert.calledOnce(dispatch); + + [action] = dispatch.firstCall.args; + assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS); + assert.deepEqual(action.data.tiles, [ + { id: 1, pos: 0, type: "organic" }, + { id: 2, pos: 1, type: "organic" }, + { id: 3, pos: 2, type: "organic" }, + ]); + }); + it("should remove visibility change listener when the wrapper is removed", () => { + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + IntersectionObserver, + }; + + const wrapper = renderImpressionStats(props); + assert.calledWith(props.document.addEventListener, "visibilitychange"); + const [, listener] = props.document.addEventListener.firstCall.args; + + wrapper.unmount(); + assert.calledWith( + props.document.removeEventListener, + "visibilitychange", + listener + ); + }); + it("should unobserve the intersection observer when the wrapper is removed", () => { + const IntersectionObserver = + buildIntersectionObserver(ZeroIntersectEntries); + const spy = sinon.spy(IntersectionObserver.prototype, "unobserve"); + const props = { dispatch: sinon.spy(), IntersectionObserver }; + + const wrapper = renderImpressionStats(props); + wrapper.unmount(); + + assert.calledOnce(spy); + }); + it("should only send the latest impression on a visibility change", () => { + const listeners = new Set(); + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: (ev, cb) => listeners.add(cb), + removeEventListener: (ev, cb) => listeners.delete(cb), + }, + }; + + const wrapper = renderImpressionStats(props); + + // Update twice + wrapper.setProps({ ...props, ...{ rows: [{ id: 123, pos: 4 }] } }); + wrapper.setProps({ ...props, ...{ rows: [{ id: 2432, pos: 5 }] } }); + + assert.notCalled(props.dispatch); + + // Simulate listeners getting called + props.document.visibilityState = "visible"; + listeners.forEach(l => l()); + + // Make sure we only sent the latest event + assert.calledTwice(props.dispatch); + const [action] = props.dispatch.firstCall.args; + assert.deepEqual(action.data.tiles, [{ id: 2432, pos: 5 }]); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx new file mode 100644 index 0000000000..ef5baf50c1 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/Navigation.test.jsx @@ -0,0 +1,131 @@ +import { + Navigation, + Topic, +} from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import React from "react"; +import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { shallow, mount } from "enzyme"; + +const DEFAULT_PROPS = { + App: { + isForStartupCache: false, + }, +}; + +describe("<Navigation>", () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(<Navigation header={{}} locale="en-US" />); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + }); + + it("should render a title", () => { + wrapper.setProps({ header: { title: "Foo" } }); + + assert.equal(wrapper.find(".ds-navigation-header").text(), "Foo"); + }); + + it("should not render a title", () => { + wrapper.setProps({ header: null }); + + assert.lengthOf(wrapper.find(".ds-navigation-header"), 0); + }); + + it("should set default alignment", () => { + assert.lengthOf(wrapper.find(".ds-navigation-centered"), 1); + }); + + it("should set custom alignment", () => { + wrapper.setProps({ alignment: "left-align" }); + + assert.lengthOf(wrapper.find(".ds-navigation-left-align"), 1); + }); + + it("should set default of no links", () => { + assert.lengthOf(wrapper.find("ul").children(), 0); + }); + + it("should render a FluentOrText", () => { + wrapper.setProps({ header: { title: "Foo" } }); + + assert.equal( + wrapper.find(".ds-navigation").children().at(0).type(), + FluentOrText + ); + }); + + it("should render 2 Topics", () => { + wrapper.setProps({ + links: [ + { url: "https://foo.com", name: "foo" }, + { url: "https://bar.com", name: "bar" }, + ], + }); + + assert.lengthOf(wrapper.find("ul").children(), 2); + }); + + it("should render 2 extra Topics", () => { + wrapper.setProps({ + newFooterSection: true, + links: [ + { url: "https://foo.com", name: "foo" }, + { url: "https://bar.com", name: "bar" }, + ], + extraLinks: [ + { url: "https://foo.com", name: "foo" }, + { url: "https://bar.com", name: "bar" }, + ], + }); + + assert.lengthOf(wrapper.find("ul").children(), 4); + }); +}); + +describe("<Topic>", () => { + let wrapper; + let sandbox; + + beforeEach(() => { + wrapper = shallow(<Topic url="https://foo.com" name="foo" />); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should pass onLinkClick prop", () => { + assert.propertyVal( + wrapper.at(0).props(), + "onLinkClick", + wrapper.instance().onLinkClick + ); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.equal(wrapper.type(), SafeAnchor); + }); + + describe("onLinkClick", () => { + let dispatch; + + beforeEach(() => { + dispatch = sandbox.stub(); + wrapper = shallow(<Topic dispatch={dispatch} {...DEFAULT_PROPS} />); + wrapper.setState({ isSeen: true }); + }); + + it("should call dispatch", () => { + wrapper.instance().onLinkClick({ target: { text: `Must Reads` } }); + + assert.calledOnce(dispatch); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx new file mode 100644 index 0000000000..285cc16c0e --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/PrivacyLink.test.jsx @@ -0,0 +1,29 @@ +import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<PrivacyLink>", () => { + let wrapper; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + wrapper = shallow( + <PrivacyLink + properties={{ + url: "url", + title: "Privacy Link", + }} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-privacy-link").exists()); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx new file mode 100644 index 0000000000..5d643869b8 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SafeAnchor.test.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { shallow } from "enzyme"; + +describe("Discovery Stream <SafeAnchor>", () => { + let warnStub; + let sandbox; + beforeEach(() => { + warnStub = sinon.stub(console, "warn"); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + warnStub.restore(); + sandbox.restore(); + }); + it("should render with anchor", () => { + const wrapper = shallow(<SafeAnchor />); + assert.lengthOf(wrapper.find("a"), 1); + }); + it("should render with anchor target for http", () => { + const wrapper = shallow(<SafeAnchor url="http://example.com" />); + assert.equal(wrapper.find("a").prop("href"), "http://example.com"); + }); + it("should render with anchor target for https", () => { + const wrapper = shallow(<SafeAnchor url="https://example.com" />); + assert.equal(wrapper.find("a").prop("href"), "https://example.com"); + }); + it("should not allow javascript: URIs", () => { + const wrapper = shallow(<SafeAnchor url="javascript:foo()" />); // eslint-disable-line no-script-url + assert.equal(wrapper.find("a").prop("href"), ""); + assert.calledOnce(warnStub); + }); + it("should not warn if the URL is falsey ", () => { + const wrapper = shallow(<SafeAnchor url="" />); + assert.equal(wrapper.find("a").prop("href"), ""); + assert.notCalled(warnStub); + }); + it("should dispatch an event on click", () => { + const dispatchStub = sandbox.stub(); + const fakeEvent = { preventDefault: sandbox.stub(), currentTarget: {} }; + const wrapper = shallow(<SafeAnchor dispatch={dispatchStub} />); + + wrapper.find("a").simulate("click", fakeEvent); + + assert.calledOnce(dispatchStub); + assert.calledOnce(fakeEvent.preventDefault); + }); + it("should call onLinkClick if provided", () => { + const onLinkClickStub = sandbox.stub(); + const wrapper = shallow(<SafeAnchor onLinkClick={onLinkClickStub} />); + + wrapper.find("a").simulate("click"); + + assert.calledOnce(onLinkClickStub); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx new file mode 100644 index 0000000000..b5ea007022 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/SectionTitle.test.jsx @@ -0,0 +1,22 @@ +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { shallow } from "enzyme"; + +describe("<SectionTitle>", () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(<SectionTitle header={{}} />); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-section-title").exists()); + }); + + it("should render a subtitle", () => { + wrapper.setProps({ header: { title: "Foo", subtitle: "Bar" } }); + + assert.equal(wrapper.find(".subtitle").text(), "Bar"); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx new file mode 100644 index 0000000000..f879600a8f --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/TopicsWidget.test.jsx @@ -0,0 +1,238 @@ +import { combineReducers, createStore } from "redux"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { Provider } from "react-redux"; +import { + _TopicsWidget as TopicsWidgetBase, + TopicsWidget, +} from "content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget"; +import { SafeAnchor } from "content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { mount } from "enzyme"; +import React from "react"; + +describe("Discovery Stream <TopicsWidget>", () => { + let sandbox; + let wrapper; + let dispatch; + let fakeWindow; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatch = sandbox.stub(); + fakeWindow = { + innerWidth: 1000, + innerHeight: 900, + }; + + wrapper = mount( + <TopicsWidgetBase + dispatch={dispatch} + source="CARDGRID_WIDGET" + position={2} + id={1} + windowObj={fakeWindow} + DiscoveryStream={{ + experimentData: { + utmCampaign: "utmCampaign", + utmContent: "utmContent", + utmSource: "utmSource", + }, + }} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".ds-topics-widget").exists()); + }); + + it("should connect with DiscoveryStream store", () => { + let store = createStore(combineReducers(reducers), INITIAL_STATE); + wrapper = mount( + <Provider store={store}> + <TopicsWidget /> + </Provider> + ); + + const topicsWidget = wrapper.find(TopicsWidgetBase); + assert.ok(topicsWidget.exists()); + assert.lengthOf(topicsWidget, 1); + assert.deepEqual( + topicsWidget.props().DiscoveryStream.experimentData, + INITIAL_STATE.DiscoveryStream.experimentData + ); + }); + + describe("dispatch", () => { + it("should dispatch loaded event", () => { + assert.callCount(dispatch, 1); + const [first] = dispatch.getCalls(); + assert.calledWith( + first, + ac.DiscoveryStreamLoadedContent({ + source: "CARDGRID_WIDGET", + tiles: [ + { + id: 1, + pos: 2, + }, + ], + }) + ); + }); + + it("should dispatch click event for technology", () => { + // Click technology topic. + wrapper.find(SafeAnchor).at(0).simulate("click"); + + // First call is DiscoveryStreamLoadedContent, which is already tested. + const [second, third, fourth] = dispatch.getCalls().slice(1, 4); + + assert.callCount(dispatch, 4); + assert.calledWith( + second, + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { + altKey: undefined, + button: undefined, + ctrlKey: undefined, + metaKey: undefined, + shiftKey: undefined, + }, + referrer: "https://getpocket.com/recommendations", + url: "https://getpocket.com/explore/technology?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign", + }, + }) + ); + assert.calledWith( + third, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "CARDGRID_WIDGET", + action_position: 2, + value: { + card_type: "topics_widget", + topic: "technology", + position_in_card: 0, + }, + }) + ); + assert.calledWith( + fourth, + ac.ImpressionStats({ + click: 0, + source: "CARDGRID_WIDGET", + tiles: [{ id: 1, pos: 2 }], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + + it("should dispatch click event for must reads", () => { + // Click must reads topic. + wrapper.find(SafeAnchor).at(8).simulate("click"); + + // First call is DiscoveryStreamLoadedContent, which is already tested. + const [second, third, fourth] = dispatch.getCalls().slice(1, 4); + + assert.callCount(dispatch, 4); + assert.calledWith( + second, + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { + altKey: undefined, + button: undefined, + ctrlKey: undefined, + metaKey: undefined, + shiftKey: undefined, + }, + referrer: "https://getpocket.com/recommendations", + url: "https://getpocket.com/collections?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign", + }, + }) + ); + assert.calledWith( + third, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "CARDGRID_WIDGET", + action_position: 2, + value: { + card_type: "topics_widget", + topic: "must-reads", + position_in_card: 8, + }, + }) + ); + assert.calledWith( + fourth, + ac.ImpressionStats({ + click: 0, + source: "CARDGRID_WIDGET", + tiles: [{ id: 1, pos: 2 }], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + + it("should dispatch click event for more topics", () => { + // Click more-topics. + wrapper.find(SafeAnchor).at(9).simulate("click"); + + // First call is DiscoveryStreamLoadedContent, which is already tested. + const [second, third, fourth] = dispatch.getCalls().slice(1, 4); + + assert.callCount(dispatch, 4); + assert.calledWith( + second, + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { + altKey: undefined, + button: undefined, + ctrlKey: undefined, + metaKey: undefined, + shiftKey: undefined, + }, + referrer: "https://getpocket.com/recommendations", + url: "https://getpocket.com/?utm_source=utmSource&utm_content=utmContent&utm_campaign=utmCampaign", + }, + }) + ); + assert.calledWith( + third, + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "CARDGRID_WIDGET", + action_position: 2, + value: { card_type: "topics_widget", topic: "more-topics" }, + }) + ); + assert.calledWith( + fourth, + ac.ImpressionStats({ + click: 0, + source: "CARDGRID_WIDGET", + tiles: [{ id: 1, pos: 2 }], + window_inner_width: 1000, + window_inner_height: 900, + }) + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx b/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx new file mode 100644 index 0000000000..99cc8b0ca7 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/ErrorBoundary.test.jsx @@ -0,0 +1,110 @@ +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import { + ErrorBoundary, + ErrorBoundaryFallback, +} from "content-src/components/ErrorBoundary/ErrorBoundary"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<ErrorBoundary>", () => { + it("should render its children if componentDidCatch wasn't called", () => { + const wrapper = shallow( + <ErrorBoundary> + <div className="kids" /> + </ErrorBoundary> + ); + + assert.lengthOf(wrapper.find(".kids"), 1); + }); + + it("should render ErrorBoundaryFallback if componentDidCatch called", () => { + const wrapper = shallow(<ErrorBoundary />); + + wrapper.instance().componentDidCatch(); + // since shallow wrappers don't automatically manage lifecycle semantics: + wrapper.update(); + + assert.lengthOf(wrapper.find(ErrorBoundaryFallback), 1); + }); + + it("should render the given FallbackComponent if componentDidCatch called", () => { + class TestFallback extends React.PureComponent { + render() { + return <div className="my-fallback">doh!</div>; + } + } + + const wrapper = shallow(<ErrorBoundary FallbackComponent={TestFallback} />); + wrapper.instance().componentDidCatch(); + // since shallow wrappers don't automatically manage lifecycle semantics: + wrapper.update(); + + assert.lengthOf(wrapper.find(TestFallback), 1); + }); + + it("should pass the given className prop to the FallbackComponent", () => { + class TestFallback extends React.PureComponent { + render() { + return <div className={this.props.className}>doh!</div>; + } + } + + const wrapper = shallow( + <ErrorBoundary FallbackComponent={TestFallback} className="sheep" /> + ); + wrapper.instance().componentDidCatch(); + // since shallow wrappers don't automatically manage lifecycle semantics: + wrapper.update(); + + assert.lengthOf(wrapper.find(".sheep"), 1); + }); +}); + +describe("ErrorBoundaryFallback", () => { + it("should render a <div> with a class of as-error-fallback", () => { + const wrapper = shallow(<ErrorBoundaryFallback />); + + assert.lengthOf(wrapper.find("div.as-error-fallback"), 1); + }); + + it("should render a <div> with the props.className and .as-error-fallback", () => { + const wrapper = shallow(<ErrorBoundaryFallback className="monkeys" />); + + assert.lengthOf(wrapper.find("div.monkeys.as-error-fallback"), 1); + }); + + it("should call window.location.reload(true) if .reload-button clicked", () => { + const fakeWindow = { location: { reload: sinon.spy() } }; + const wrapper = shallow(<ErrorBoundaryFallback windowObj={fakeWindow} />); + + wrapper.find(".reload-button").simulate("click"); + + assert.calledOnce(fakeWindow.location.reload); + assert.calledWithExactly(fakeWindow.location.reload, true); + }); + + it("should render .reload-button as an <A11yLinkButton>", () => { + const wrapper = shallow(<ErrorBoundaryFallback />); + + assert.lengthOf(wrapper.find("A11yLinkButton.reload-button"), 1); + }); + + it("should render newtab-error-fallback-refresh-link node", () => { + const wrapper = shallow(<ErrorBoundaryFallback />); + + const msgWrapper = wrapper.find( + '[data-l10n-id="newtab-error-fallback-refresh-link"]' + ); + assert.lengthOf(msgWrapper, 1); + assert.isTrue(msgWrapper.is(A11yLinkButton)); + }); + + it("should render newtab-error-fallback-info node", () => { + const wrapper = shallow(<ErrorBoundaryFallback />); + + const msgWrapper = wrapper.find( + '[data-l10n-id="newtab-error-fallback-info"]' + ); + assert.lengthOf(msgWrapper, 1); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx b/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx new file mode 100644 index 0000000000..165f2a6dcf --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/FluentOrText.test.jsx @@ -0,0 +1,68 @@ +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import React from "react"; +import { shallow, mount } from "enzyme"; + +describe("<FluentOrText>", () => { + it("should create span with no children", () => { + const wrapper = shallow(<FluentOrText />); + + assert.ok(wrapper.find("span").exists()); + }); + it("should set plain text", () => { + const wrapper = shallow(<FluentOrText message={"hello"} />); + + assert.equal(wrapper.text(), "hello"); + }); + it("should use fluent id on automatic span", () => { + const wrapper = shallow(<FluentOrText message={{ id: "fluent" }} />); + + assert.ok(wrapper.find("span[data-l10n-id='fluent']").exists()); + }); + it("should also allow string_id", () => { + const wrapper = shallow(<FluentOrText message={{ string_id: "fluent" }} />); + + assert.ok(wrapper.find("span[data-l10n-id='fluent']").exists()); + }); + it("should use fluent id on child", () => { + const wrapper = shallow( + <FluentOrText message={{ id: "fluent" }}> + <p /> + </FluentOrText> + ); + + assert.ok(wrapper.find("p[data-l10n-id='fluent']").exists()); + }); + it("should set args for fluent", () => { + const wrapper = mount(<FluentOrText message={{ args: { num: 5 } }} />); + const { attributes } = wrapper.getDOMNode(); + const args = attributes.getNamedItem("data-l10n-args").value; + assert.equal(JSON.parse(args).num, 5); + }); + it("should also allow values", () => { + const wrapper = mount(<FluentOrText message={{ values: { num: 5 } }} />); + const { attributes } = wrapper.getDOMNode(); + const args = attributes.getNamedItem("data-l10n-args").value; + assert.equal(JSON.parse(args).num, 5); + }); + it("should preserve original children with fluent", () => { + const wrapper = shallow( + <FluentOrText message={{ id: "fluent" }}> + <p> + <b data-l10n-name="bold" /> + </p> + </FluentOrText> + ); + + assert.ok(wrapper.find("b[data-l10n-name='bold']").exists()); + }); + it("should only allow a single child", () => { + assert.throws(() => + shallow( + <FluentOrText> + <p /> + <p /> + </FluentOrText> + ) + ); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx b/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx new file mode 100644 index 0000000000..e2cf4f1f21 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/HelpText.test.jsx @@ -0,0 +1,41 @@ +import { HelpText } from "content-src/aboutwelcome/components/HelpText"; +import { Localized } from "content-src/aboutwelcome/components/MSLocalized"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<HelpText>", () => { + it("should render text inside Localized", () => { + const shallowWrapper = shallow(<HelpText text="test" />); + + assert.equal(shallowWrapper.find(Localized).props().text, "test"); + }); + it("should render the img if there is an img and a string_id", () => { + const shallowWrapper = shallow( + <HelpText + text={{ string_id: "test_id" }} + hasImg={{ + src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png", + }} + /> + ); + assert.ok( + shallowWrapper + .find(Localized) + .findWhere(n => n.text.string_id === "test_id") + ); + assert.lengthOf(shallowWrapper.find("p.helptext"), 1); + assert.lengthOf(shallowWrapper.find("img[data-l10n-name='help-img']"), 1); + }); + it("should render the img if there is an img and plain text", () => { + const shallowWrapper = shallow( + <HelpText + text={"Sample help text"} + hasImg={{ + src: "chrome://activity-stream/content/data/content/assets/cfr_fb_container.png", + }} + /> + ); + assert.equal(shallowWrapper.find("p.helptext").text(), "Sample help text"); + assert.lengthOf(shallowWrapper.find("img.helptext-img"), 1); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx new file mode 100644 index 0000000000..8aa74a3a46 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx @@ -0,0 +1,582 @@ +import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; +import { _LinkMenu as LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<LinkMenu>", () => { + let wrapper; + beforeEach(() => { + wrapper = shallow( + <LinkMenu + site={{ url: "" }} + options={["CheckPinTopSite", "CheckBookmark", "OpenInNewWindow"]} + dispatch={() => {}} + /> + ); + }); + it("should render a ContextMenu element", () => { + assert.ok(wrapper.find(ContextMenu).exists()); + }); + it("should pass onUpdate, and options to ContextMenu", () => { + assert.ok(wrapper.find(ContextMenu).exists()); + const contextMenuProps = wrapper.find(ContextMenu).props(); + ["onUpdate", "options"].forEach(prop => + assert.property(contextMenuProps, prop) + ); + }); + it("should give ContextMenu the correct tabbable options length for a11y", () => { + const { options } = wrapper.find(ContextMenu).props(); + const [firstItem] = options; + const lastItem = options[options.length - 1]; + + // first item should have {first: true} + assert.isTrue(firstItem.first); + assert.ok(!firstItem.last); + + // last item should have {last: true} + assert.isTrue(lastItem.last); + assert.ok(!lastItem.first); + + // middle items should have neither + for (let i = 1; i < options.length - 1; i++) { + assert.ok(!options[i].first && !options[i].last); + } + }); + it("should show the correct options for default sites", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", isDefault: true }} + options={["CheckBookmark"]} + source={"TOP_SITES"} + isPrivateBrowsingEnabled={true} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + let i = 0; + assert.propertyVal(options[i++], "id", "newtab-menu-pin"); + assert.propertyVal(options[i++], "id", "newtab-menu-edit-topsites"); + assert.propertyVal(options[i++], "type", "separator"); + assert.propertyVal(options[i++], "id", "newtab-menu-open-new-window"); + assert.propertyVal( + options[i++], + "id", + "newtab-menu-open-new-private-window" + ); + assert.propertyVal(options[i++], "type", "separator"); + assert.propertyVal(options[i++], "id", "newtab-menu-dismiss"); + assert.propertyVal(options, "length", i); + // Double check that delete options are not included for default top sites + options + .filter(o => o.type !== "separator") + .forEach(o => { + assert.notInclude(["newtab-menu-delete-history"], o.id); + }); + }); + it("should show Unpin option for a pinned site if CheckPinTopSite in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", isPinned: true }} + source={"TOP_SITES"} + options={["CheckPinTopSite"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-unpin")); + }); + it("should show Pin option for an unpinned site if CheckPinTopSite in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", isPinned: false }} + source={"TOP_SITES"} + options={["CheckPinTopSite"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined(options.find(o => o.id && o.id === "newtab-menu-pin")); + }); + it("should show Unbookmark option for a bookmarked site if CheckBookmark in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", bookmarkGuid: 1234 }} + source={"TOP_SITES"} + options={["CheckBookmark"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-remove-bookmark") + ); + }); + it("should show Bookmark option for an unbookmarked site if CheckBookmark in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", bookmarkGuid: 0 }} + source={"TOP_SITES"} + options={["CheckBookmark"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-bookmark") + ); + }); + it("should show Save to Pocket option for an unsaved Pocket item if CheckSavedToPocket in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", bookmarkGuid: 0 }} + source={"HIGHLIGHTS"} + options={["CheckSavedToPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-save-to-pocket") + ); + }); + it("should show Delete from Pocket option for a saved Pocket item if CheckSavedToPocket in options list", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", pocket_id: 1234 }} + source={"HIGHLIGHTS"} + options={["CheckSavedToPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-delete-pocket") + ); + }); + it("should show Archive from Pocket option for a saved Pocket item if CheckBookmarkOrArchive", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", pocket_id: 1234 }} + source={"HIGHLIGHTS"} + options={["CheckBookmarkOrArchive"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-archive-pocket") + ); + }); + it("should show Bookmark option for an unbookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "" }} + source={"HIGHLIGHTS"} + options={["CheckBookmarkOrArchive"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-bookmark") + ); + }); + it("should show Unbookmark option for a bookmarked site if CheckBookmarkOrArchive in options list and no pocket_id", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", bookmarkGuid: 1234 }} + source={"HIGHLIGHTS"} + options={["CheckBookmarkOrArchive"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-remove-bookmark") + ); + }); + it("should show Archive from Pocket option for a saved Pocket item if CheckArchiveFromPocket", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", pocket_id: 1234 }} + source={"TOP_STORIES"} + options={["CheckArchiveFromPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-archive-pocket") + ); + }); + it("should show empty from no Pocket option for no saved Pocket item if CheckArchiveFromPocket", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "" }} + source={"TOP_STORIES"} + options={["CheckArchiveFromPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isUndefined( + options.find(o => o.id && o.id === "newtab-menu-archive-pocket") + ); + }); + it("should show Delete from Pocket option for a saved Pocket item if CheckDeleteFromPocket", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", pocket_id: 1234 }} + source={"TOP_STORIES"} + options={["CheckDeleteFromPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-delete-pocket") + ); + }); + it("should show empty from Pocket option for no saved Pocket item if CheckDeleteFromPocket", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "" }} + source={"TOP_STORIES"} + options={["CheckDeleteFromPocket"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isUndefined( + options.find(o => o.id && o.id === "newtab-menu-archive-pocket") + ); + }); + it("should show Open File option for a downloaded item", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download", path: "foo" }} + source={"HIGHLIGHTS"} + options={["OpenFile"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-open-file") + ); + }); + it("should show Show File option for a downloaded item on a default platform", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download", path: "foo" }} + source={"HIGHLIGHTS"} + options={["ShowFile"]} + platform={"default"} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-show-file") + ); + }); + it("should show Copy Downlad Link option for a downloaded item when CopyDownloadLink", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download" }} + source={"HIGHLIGHTS"} + options={["CopyDownloadLink"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-copy-download-link") + ); + }); + it("should show Go To Download Page option for a downloaded item when GoToDownloadPage", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download", referrer: "foo" }} + source={"HIGHLIGHTS"} + options={["GoToDownloadPage"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-go-to-download-page") + ); + assert.isFalse(options[0].disabled); + }); + it("should show Go To Download Page option as disabled for a downloaded item when GoToDownloadPage if no referrer exists", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download", referrer: null }} + source={"HIGHLIGHTS"} + options={["GoToDownloadPage"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-go-to-download-page") + ); + assert.isTrue(options[0].disabled); + }); + it("should show Remove Download Link option for a downloaded item when RemoveDownload", () => { + wrapper = shallow( + <LinkMenu + site={{ url: "", type: "download" }} + source={"HIGHLIGHTS"} + options={["RemoveDownload"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + assert.isDefined( + options.find(o => o.id && o.id === "newtab-menu-remove-download") + ); + }); + it("should show Edit option", () => { + const props = { url: "foo", label: "label" }; + const index = 5; + wrapper = shallow( + <LinkMenu + site={props} + index={5} + source={"TOP_SITES"} + options={["EditTopSite"]} + dispatch={() => {}} + /> + ); + const { options } = wrapper.find(ContextMenu).props(); + const option = options.find( + o => o.id && o.id === "newtab-menu-edit-topsites" + ); + assert.isDefined(option); + assert.equal(option.action.data.index, index); + }); + describe(".onClick", () => { + const FAKE_EVENT = {}; + const FAKE_INDEX = 3; + const FAKE_SOURCE = "TOP_SITES"; + const FAKE_SITE = { + bookmarkGuid: 1234, + hostname: "foo", + path: "foo", + pocket_id: "1234", + referrer: "https://foo.com/ref", + title: "bar", + type: "bookmark", + typedBonus: true, + url: "https://foo.com", + sponsored_tile_id: 12345, + }; + const dispatch = sinon.stub(); + const propOptions = [ + "ShowFile", + "CopyDownloadLink", + "GoToDownloadPage", + "RemoveDownload", + "Separator", + "ShowPrivacyInfo", + "RemoveBookmark", + "AddBookmark", + "OpenInNewWindow", + "OpenInPrivateWindow", + "BlockUrl", + "DeleteUrl", + "PinTopSite", + "UnpinTopSite", + "SaveToPocket", + "DeleteFromPocket", + "ArchiveFromPocket", + "WebExtDismiss", + ]; + const expectedActionData = { + "newtab-menu-remove-bookmark": FAKE_SITE.bookmarkGuid, + "newtab-menu-bookmark": { + url: FAKE_SITE.url, + title: FAKE_SITE.title, + type: FAKE_SITE.type, + }, + "newtab-menu-open-new-window": { + url: FAKE_SITE.url, + referrer: FAKE_SITE.referrer, + typedBonus: FAKE_SITE.typedBonus, + sponsored_tile_id: FAKE_SITE.sponsored_tile_id, + }, + "newtab-menu-open-new-private-window": { + url: FAKE_SITE.url, + referrer: FAKE_SITE.referrer, + }, + "newtab-menu-dismiss": [ + { + url: FAKE_SITE.url, + pocket_id: FAKE_SITE.pocket_id, + isSponsoredTopSite: undefined, + }, + ], + menu_action_webext_dismiss: { + source: "TOP_SITES", + url: FAKE_SITE.url, + action_position: 3, + }, + "newtab-menu-delete-history": { + url: FAKE_SITE.url, + pocket_id: FAKE_SITE.pocket_id, + forceBlock: FAKE_SITE.bookmarkGuid, + }, + "newtab-menu-pin": { site: FAKE_SITE, index: FAKE_INDEX }, + "newtab-menu-unpin": { site: { url: FAKE_SITE.url } }, + "newtab-menu-save-to-pocket": { + site: { url: FAKE_SITE.url, title: FAKE_SITE.title }, + }, + "newtab-menu-delete-pocket": { pocket_id: "1234" }, + "newtab-menu-archive-pocket": { pocket_id: "1234" }, + "newtab-menu-show-file": { url: FAKE_SITE.url }, + "newtab-menu-copy-download-link": { url: FAKE_SITE.url }, + "newtab-menu-go-to-download-page": { url: FAKE_SITE.referrer }, + "newtab-menu-remove-download": { url: FAKE_SITE.url }, + }; + const { options } = shallow( + <LinkMenu + site={FAKE_SITE} + siteInfo={{ value: { card_type: FAKE_SITE.type } }} + dispatch={dispatch} + index={FAKE_INDEX} + isPrivateBrowsingEnabled={true} + platform={"default"} + options={propOptions} + source={FAKE_SOURCE} + shouldSendImpressionStats={true} + /> + ) + .find(ContextMenu) + .props(); + afterEach(() => dispatch.reset()); + options + .filter(o => o.type !== "separator") + .forEach(option => { + it(`should fire a ${option.action.type} action for ${option.id} with the expected data`, () => { + option.onClick(FAKE_EVENT); + + if (option.impression && option.userEvent) { + assert.calledThrice(dispatch); + } else if (option.impression || option.userEvent) { + assert.calledTwice(dispatch); + } else { + assert.calledOnce(dispatch); + } + + // option.action is dispatched + assert.ok(dispatch.firstCall.calledWith(option.action)); + + // option.action has correct data + // (delete is a special case as it dispatches a nested DIALOG_OPEN-type action) + // in the case of this FAKE_SITE, we send a bookmarkGuid therefore we also want + // to block this if we delete it + if (option.id === "newtab-menu-delete-history") { + assert.deepEqual( + option.action.data.onConfirm[0].data, + expectedActionData[option.id] + ); + // Test UserEvent send correct meta about item deleted + assert.propertyVal( + option.action.data.onConfirm[1].data, + "action_position", + FAKE_INDEX + ); + assert.propertyVal( + option.action.data.onConfirm[1].data, + "source", + FAKE_SOURCE + ); + } else { + assert.deepEqual(option.action.data, expectedActionData[option.id]); + } + }); + it(`should fire a UserEvent action for ${option.id} if configured`, () => { + if (option.userEvent) { + option.onClick(FAKE_EVENT); + const [action] = dispatch.secondCall.args; + assert.isUserEventAction(action); + assert.propertyVal(action.data, "source", FAKE_SOURCE); + assert.propertyVal(action.data, "action_position", FAKE_INDEX); + assert.propertyVal(action.data.value, "card_type", FAKE_SITE.type); + } + }); + it(`should send impression stats for ${option.id}`, () => { + if (option.impression) { + option.onClick(FAKE_EVENT); + const [action] = dispatch.thirdCall.args; + assert.deepEqual(action, option.impression); + } + }); + }); + it(`should not send impression stats if not configured`, () => { + const fakeOptions = shallow( + <LinkMenu + site={FAKE_SITE} + dispatch={dispatch} + index={FAKE_INDEX} + options={propOptions} + source={FAKE_SOURCE} + shouldSendImpressionStats={false} + /> + ) + .find(ContextMenu) + .props().options; + + fakeOptions + .filter(o => o.type !== "separator") + .forEach(option => { + if (option.impression) { + option.onClick(FAKE_EVENT); + assert.calledTwice(dispatch); + assert.notEqual(dispatch.firstCall.args[0], option.impression); + assert.notEqual(dispatch.secondCall.args[0], option.impression); + dispatch.reset(); + } + }); + }); + it(`should pin a SPOC with all of the site details sent`, () => { + const pinSpocTopSite = "PinTopSite"; + const { options: spocOptions } = shallow( + <LinkMenu + site={FAKE_SITE} + siteInfo={{ value: { card_type: FAKE_SITE.type } }} + dispatch={dispatch} + index={FAKE_INDEX} + isPrivateBrowsingEnabled={true} + platform={"default"} + options={[pinSpocTopSite]} + source={FAKE_SOURCE} + shouldSendImpressionStats={true} + /> + ) + .find(ContextMenu) + .props(); + + const [pinSpocOption] = spocOptions; + pinSpocOption.onClick(FAKE_EVENT); + + if (pinSpocOption.impression && pinSpocOption.userEvent) { + assert.calledThrice(dispatch); + } else if (pinSpocOption.impression || pinSpocOption.userEvent) { + assert.calledTwice(dispatch); + } else { + assert.calledOnce(dispatch); + } + + // option.action is dispatched + assert.ok(dispatch.firstCall.calledWith(pinSpocOption.action)); + + assert.deepEqual(pinSpocOption.action.data, { + site: FAKE_SITE, + index: FAKE_INDEX, + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx b/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx new file mode 100644 index 0000000000..d46f794513 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/MSLocalized.test.jsx @@ -0,0 +1,48 @@ +import { Localized } from "content-src/aboutwelcome/components/MSLocalized"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<MSLocalized>", () => { + it("should render span with no children", () => { + const shallowWrapper = shallow(<Localized text="test" />); + + assert.ok(shallowWrapper.find("span").exists()); + assert.equal(shallowWrapper.text(), "test"); + }); + it("should render span when using string_id with no children", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }} /> + ); + + assert.ok(shallowWrapper.find("span[data-l10n-id='test_id']").exists()); + }); + it("should render text inside child", () => { + const shallowWrapper = shallow( + <Localized text="test"> + <div /> + </Localized> + ); + + assert.ok(shallowWrapper.find("div").text(), "test"); + }); + it("should use l10n id on child", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }}> + <div /> + </Localized> + ); + + assert.ok(shallowWrapper.find("div[data-l10n-id='test_id']").exists()); + }); + it("should keep original children", () => { + const shallowWrapper = shallow( + <Localized text={{ string_id: "test_id" }}> + <h1> + <span data-l10n-name="test" /> + </h1> + </Localized> + ); + + assert.ok(shallowWrapper.find("span[data-l10n-name='test']").exists()); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx b/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx new file mode 100644 index 0000000000..2b3c06b6bf --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/MoreRecommendations.test.jsx @@ -0,0 +1,24 @@ +import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<MoreRecommendations>", () => { + it("should render a MoreRecommendations element", () => { + const wrapper = shallow(<MoreRecommendations />); + assert.ok(wrapper.exists()); + }); + it("should render a link when provided with read_more_endpoint prop", () => { + const wrapper = shallow( + <MoreRecommendations read_more_endpoint="https://endpoint.com" /> + ); + + const link = wrapper.find(".more-recommendations"); + assert.lengthOf(link, 1); + }); + it("should not render a link when provided with read_more_endpoint prop", () => { + const wrapper = shallow(<MoreRecommendations read_more_endpoint="" />); + + const link = wrapper.find(".more-recommendations"); + assert.lengthOf(link, 0); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx b/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx new file mode 100644 index 0000000000..31a5e7be4d --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/PocketLoggedInCta.test.jsx @@ -0,0 +1,46 @@ +import { combineReducers, createStore } from "redux"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { mount, shallow } from "enzyme"; +import { + PocketLoggedInCta, + _PocketLoggedInCta as PocketLoggedInCtaRaw, +} from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import { Provider } from "react-redux"; +import React from "react"; + +function mountSectionWithProps(props) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <PocketLoggedInCta {...props} /> + </Provider> + ); +} + +describe("<PocketLoggedInCta>", () => { + it("should render a PocketLoggedInCta element", () => { + const wrapper = mountSectionWithProps({}); + assert.ok(wrapper.exists()); + }); + it("should render Fluent spans when rendered without props", () => { + const wrapper = mountSectionWithProps({}); + + const message = wrapper.find("span[data-l10n-id]"); + assert.lengthOf(message, 2); + }); + it("should not render Fluent spans when rendered with props", () => { + const wrapper = shallow( + <PocketLoggedInCtaRaw + Pocket={{ + pocketCta: { + ctaButton: "button", + ctaText: "text", + }, + }} + /> + ); + + const message = wrapper.find("span[data-l10n-id]"); + assert.lengthOf(message, 0); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/Search.test.jsx b/browser/components/newtab/test/unit/content-src/components/Search.test.jsx new file mode 100644 index 0000000000..54a3b611cc --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/Search.test.jsx @@ -0,0 +1,179 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { mount, shallow } from "enzyme"; +import React from "react"; +import { _Search as Search } from "content-src/components/Search/Search"; + +const DEFAULT_PROPS = { + dispatch() {}, + Prefs: { values: { featureConfig: {} } }, +}; + +describe("<Search>", () => { + let globals; + let sandbox; + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + + global.ContentSearchUIController.prototype = { search: sandbox.spy() }; + }); + afterEach(() => { + globals.restore(); + }); + + it("should render a Search element", () => { + const wrapper = shallow(<Search {...DEFAULT_PROPS} />); + assert.ok(wrapper.exists()); + }); + it("should not use a <form> element", () => { + const wrapper = mount(<Search {...DEFAULT_PROPS} />); + + assert.equal(wrapper.find("form").length, 0); + }); + it("should listen for ContentSearchClient on render", () => { + const spy = globals.set("addEventListener", sandbox.spy()); + + const wrapper = mount(<Search {...DEFAULT_PROPS} />); + + assert.calledOnce(spy.withArgs("ContentSearchClient", wrapper.instance())); + }); + it("should stop listening for ContentSearchClient on unmount", () => { + const spy = globals.set("removeEventListener", sandbox.spy()); + const wrapper = mount(<Search {...DEFAULT_PROPS} />); + // cache the instance as we can't call this method after unmount is called + const instance = wrapper.instance(); + + wrapper.unmount(); + + assert.calledOnce(spy.withArgs("ContentSearchClient", instance)); + }); + it("should add gContentSearchController as a global", () => { + // current about:home tests need gContentSearchController to exist as a global + // so let's test it here too to ensure we don't break this behaviour + mount(<Search {...DEFAULT_PROPS} />); + assert.property(window, "gContentSearchController"); + assert.ok(window.gContentSearchController); + }); + it("should pass along search when clicking the search button", () => { + const wrapper = mount(<Search {...DEFAULT_PROPS} />); + + wrapper.find(".search-button").simulate("click"); + + const { search } = window.gContentSearchController; + assert.calledOnce(search); + assert.propertyVal(search.firstCall.args[0], "type", "click"); + }); + it("should send a UserEvent action", () => { + global.ContentSearchUIController.prototype.search = () => { + dispatchEvent( + new CustomEvent("ContentSearchClient", { detail: { type: "Search" } }) + ); + }; + const dispatch = sinon.spy(); + const wrapper = mount(<Search {...DEFAULT_PROPS} dispatch={dispatch} />); + + wrapper.find(".search-button").simulate("click"); + + assert.calledOnce(dispatch); + const [action] = dispatch.firstCall.args; + assert.isUserEventAction(action); + assert.propertyVal(action.data, "event", "SEARCH"); + }); + it("should show our logo when the prop exists.", () => { + const showLogoProps = Object.assign({}, DEFAULT_PROPS, { showLogo: true }); + + const wrapper = shallow(<Search {...showLogoProps} />); + assert.lengthOf(wrapper.find(".logo-and-wordmark"), 1); + }); + it("should not show our logo when the prop does not exist.", () => { + const hideLogoProps = Object.assign({}, DEFAULT_PROPS, { showLogo: false }); + + const wrapper = shallow(<Search {...hideLogoProps} />); + assert.lengthOf(wrapper.find(".logo-and-wordmark"), 0); + }); + + describe("Search Hand-off", () => { + it("should render a Search element when hand-off is enabled", () => { + const wrapper = shallow( + <Search {...DEFAULT_PROPS} handoffEnabled={true} /> + ); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".search-handoff-button").length, 1); + }); + it("should hand-off search when button is clicked", () => { + const dispatch = sinon.spy(); + const wrapper = shallow( + <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} /> + ); + wrapper + .find(".search-handoff-button") + .simulate("click", { preventDefault: () => {} }); + assert.calledThrice(dispatch); + assert.calledWith(dispatch, { + data: { text: undefined }, + meta: { + from: "ActivityStream:Content", + skipLocal: true, + to: "ActivityStream:Main", + }, + type: "HANDOFF_SEARCH_TO_AWESOMEBAR", + }); + assert.calledWith(dispatch, { type: "FAKE_FOCUS_SEARCH" }); + const [action] = dispatch.thirdCall.args; + assert.isUserEventAction(action); + assert.propertyVal(action.data, "event", "SEARCH_HANDOFF"); + }); + it("should hand-off search on paste", () => { + const dispatch = sinon.spy(); + const wrapper = mount( + <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} /> + ); + wrapper.instance()._searchHandoffButton = { contains: () => true }; + wrapper.instance().onSearchHandoffPaste({ + clipboardData: { + getData: () => "some copied text", + }, + preventDefault: () => {}, + }); + assert.equal(dispatch.callCount, 4); + assert.calledWith(dispatch, { + data: { text: "some copied text" }, + meta: { + from: "ActivityStream:Content", + skipLocal: true, + to: "ActivityStream:Main", + }, + type: "HANDOFF_SEARCH_TO_AWESOMEBAR", + }); + assert.calledWith(dispatch, { type: "DISABLE_SEARCH" }); + const [action] = dispatch.thirdCall.args; + assert.isUserEventAction(action); + assert.propertyVal(action.data, "event", "SEARCH_HANDOFF"); + }); + it("should properly handle drop events", () => { + const dispatch = sinon.spy(); + const wrapper = mount( + <Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} /> + ); + const preventDefault = sinon.spy(); + wrapper.find(".fake-editable").simulate("drop", { + dataTransfer: { getData: () => "dropped text" }, + preventDefault, + }); + assert.equal(dispatch.callCount, 4); + assert.calledWith(dispatch, { + data: { text: "dropped text" }, + meta: { + from: "ActivityStream:Content", + skipLocal: true, + to: "ActivityStream:Main", + }, + type: "HANDOFF_SEARCH_TO_AWESOMEBAR", + }); + assert.calledWith(dispatch, { type: "DISABLE_SEARCH" }); + const [action] = dispatch.thirdCall.args; + assert.isUserEventAction(action); + assert.propertyVal(action.data, "event", "SEARCH_HANDOFF"); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx new file mode 100644 index 0000000000..9f4008369a --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/Sections.test.jsx @@ -0,0 +1,600 @@ +import { combineReducers, createStore } from "redux"; +import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; +import { + Section, + SectionIntl, + _Sections as Sections, +} from "content-src/components/Sections/Sections"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { mount, shallow } from "enzyme"; +import { PlaceholderCard } from "content-src/components/Card/Card"; +import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import { Provider } from "react-redux"; +import React from "react"; +import { Topics } from "content-src/components/Topics/Topics"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +function mountSectionWithProps(props) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <Section {...props} /> + </Provider> + ); +} + +function mountSectionIntlWithProps(props) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <SectionIntl {...props} /> + </Provider> + ); +} + +describe("<Sections>", () => { + let wrapper; + let FAKE_SECTIONS; + beforeEach(() => { + FAKE_SECTIONS = new Array(5).fill(null).map((v, i) => ({ + id: `foo_bar_${i}`, + title: `Foo Bar ${i}`, + enabled: !!(i % 2), + rows: [], + })); + wrapper = shallow( + <Sections + Sections={FAKE_SECTIONS} + Prefs={{ + values: { sectionOrder: FAKE_SECTIONS.map(i => i.id).join(",") }, + }} + /> + ); + }); + it("should render a Sections element", () => { + assert.ok(wrapper.exists()); + }); + it("should render a Section for each one passed in props.Sections with .enabled === true", () => { + const sectionElems = wrapper.find(SectionIntl); + assert.lengthOf(sectionElems, 2); + sectionElems.forEach((section, i) => { + assert.equal(section.props().id, FAKE_SECTIONS[2 * i + 1].id); + assert.equal(section.props().enabled, true); + }); + }); + it("should render Top Sites if feeds.topsites pref is true", () => { + wrapper = shallow( + <Sections + Sections={FAKE_SECTIONS} + Prefs={{ + values: { + "feeds.topsites": true, + sectionOrder: "topsites,topstories,highlights", + }, + }} + /> + ); + assert.equal(wrapper.find(TopSites).length, 1); + }); + it("should NOT render Top Sites if feeds.topsites pref is false", () => { + wrapper = shallow( + <Sections + Sections={FAKE_SECTIONS} + Prefs={{ + values: { + "feeds.topsites": false, + sectionOrder: "topsites,topstories,highlights", + }, + }} + /> + ); + assert.equal(wrapper.find(TopSites).length, 0); + }); + it("should render the sections in the order specifed by sectionOrder pref", () => { + wrapper = shallow( + <Sections + Sections={FAKE_SECTIONS} + Prefs={{ values: { sectionOrder: "foo_bar_1,foo_bar_3" } }} + /> + ); + let sections = wrapper.find(SectionIntl); + assert.lengthOf(sections, 2); + assert.equal(sections.first().props().id, "foo_bar_1"); + assert.equal(sections.last().props().id, "foo_bar_3"); + wrapper = shallow( + <Sections + Sections={FAKE_SECTIONS} + Prefs={{ values: { sectionOrder: "foo_bar_3,foo_bar_1" } }} + /> + ); + sections = wrapper.find(SectionIntl); + assert.lengthOf(sections, 2); + assert.equal(sections.first().props().id, "foo_bar_3"); + assert.equal(sections.last().props().id, "foo_bar_1"); + }); +}); + +describe("<Section>", () => { + let wrapper; + let FAKE_SECTION; + + beforeEach(() => { + FAKE_SECTION = { + id: `foo_bar_1`, + pref: { collapsed: false }, + title: `Foo Bar 1`, + rows: [{ link: "http://localhost", index: 0 }], + emptyState: { + icon: "check", + message: "Some message", + }, + rowsPref: "section.rows", + maxRows: 4, + Prefs: { values: { "section.rows": 2 } }, + }; + wrapper = mountSectionIntlWithProps(FAKE_SECTION); + }); + + describe("placeholders", () => { + const CARDS_PER_ROW = 3; + const fakeSite = { link: "http://localhost" }; + function renderWithSites(rows) { + const store = createStore(combineReducers(reducers), INITIAL_STATE); + return mount( + <Provider store={store}> + <Section {...FAKE_SECTION} rows={rows} /> + </Provider> + ); + } + + it("should return 2 row of placeholders if realRows is 0", () => { + wrapper = renderWithSites([]); + assert.lengthOf(wrapper.find(PlaceholderCard), 6); + }); + it("should fill in the rest of the rows", () => { + wrapper = renderWithSites(new Array(CARDS_PER_ROW).fill(fakeSite)); + assert.lengthOf( + wrapper.find(PlaceholderCard), + CARDS_PER_ROW, + "CARDS_PER_ROW" + ); + + wrapper = renderWithSites(new Array(CARDS_PER_ROW + 1).fill(fakeSite)); + assert.lengthOf(wrapper.find(PlaceholderCard), 2, "CARDS_PER_ROW + 1"); + + wrapper = renderWithSites(new Array(CARDS_PER_ROW + 2).fill(fakeSite)); + assert.lengthOf(wrapper.find(PlaceholderCard), 1, "CARDS_PER_ROW + 2"); + + wrapper = renderWithSites( + new Array(2 * CARDS_PER_ROW - 1).fill(fakeSite) + ); + assert.lengthOf(wrapper.find(PlaceholderCard), 1, "CARDS_PER_ROW - 1"); + }); + it("should not add placeholders all the rows are full", () => { + wrapper = renderWithSites(new Array(2 * CARDS_PER_ROW).fill(fakeSite)); + assert.lengthOf(wrapper.find(PlaceholderCard), 0, "2 rows"); + }); + }); + + describe("empty state", () => { + beforeEach(() => { + Object.assign(FAKE_SECTION, { + initialized: true, + dispatch: () => {}, + rows: [], + emptyState: { + message: "Some message", + }, + }); + wrapper = shallow(<Section {...FAKE_SECTION} />); + }); + it("should be shown when rows is empty and initialized is true", () => { + assert.ok(wrapper.find(".empty-state").exists()); + }); + it("should not be shown in initialized is false", () => { + Object.assign(FAKE_SECTION, { + initialized: false, + rows: [], + emptyState: { + message: "Some message", + }, + }); + wrapper = shallow(<Section {...FAKE_SECTION} />); + assert.isFalse(wrapper.find(".empty-state").exists()); + }); + it("no icon should be shown", () => { + assert.lengthOf(wrapper.find(".icon"), 0); + }); + }); + + describe("topics component", () => { + let TOP_STORIES_SECTION; + beforeEach(() => { + TOP_STORIES_SECTION = { + id: "topstories", + title: "TopStories", + pref: { collapsed: false }, + rows: [{ guid: 1, link: "http://localhost", isDefault: true }], + topics: [], + read_more_endpoint: "http://localhost/read-more", + maxRows: 1, + eventSource: "TOP_STORIES", + }; + }); + it("should not render for empty topics", () => { + wrapper = mountSectionIntlWithProps(TOP_STORIES_SECTION); + + assert.lengthOf(wrapper.find(".topic"), 0); + }); + it("should render for non-empty topics", () => { + TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }]; + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: true }, isUserLoggedIn: true }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 1); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + }); + it("should delay render of third rec to give time for potential spoc", async () => { + TOP_STORIES_SECTION.rows = [ + { guid: 1, link: "http://localhost" }, + { guid: 2, link: "http://localhost" }, + { guid: 3, link: "http://localhost" }, + ]; + wrapper = shallow( + <Section + Pocket={{ waitingForSpoc: true, pocketCta: {} }} + {...TOP_STORIES_SECTION} + /> + ); + assert.lengthOf(wrapper.find(PlaceholderCard), 1); + + wrapper.setProps({ + Pocket: { + waitingForSpoc: false, + pocketCta: {}, + }, + }); + assert.lengthOf(wrapper.find(PlaceholderCard), 0); + }); + it("should render container for uninitialized topics to ensure content doesn't shift", () => { + delete TOP_STORIES_SECTION.topics; + + wrapper = mountSectionIntlWithProps(TOP_STORIES_SECTION); + + assert.lengthOf(wrapper.find(".top-stories-bottom-container"), 1); + assert.lengthOf(wrapper.find(Topics), 0); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + }); + + it("should render a pocket cta if not logged in and set to display cta", () => { + TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }]; + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: true }, isUserLoggedIn: false }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 0); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 1); + }); + it("should render nothing while loading to avoid a flicker of log in state", () => { + TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }]; + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: false } }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 0); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + }); + it("should render a topics list if set to not display cta with either logged or out", () => { + TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }]; + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: false }, isUserLoggedIn: false }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 1); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: false }, isUserLoggedIn: true }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 1); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + }); + it("should render nothing if set to display a cta and not logged in or out (waiting for state)", () => { + TOP_STORIES_SECTION.topics = [{ name: "topic1", url: "topic-url1" }]; + wrapper = shallow( + <Section + Pocket={{ pocketCta: { useCta: true } }} + {...TOP_STORIES_SECTION} + /> + ); + + assert.lengthOf(wrapper.find(Topics), 0); + assert.lengthOf(wrapper.find(PocketLoggedInCta), 0); + }); + }); + + describe("impression stats", () => { + const FAKE_TOPSTORIES_SECTION_PROPS = { + id: "TopStories", + title: "Foo Bar 1", + pref: { collapsed: false }, + maxRows: 1, + rows: [{ guid: 1 }, { guid: 2 }], + shouldSendImpressionStats: true, + + document: { + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }, + eventSource: "TOP_STORIES", + options: { personalized: false }, + }; + + function renderSection(props = {}) { + return shallow(<Section {...FAKE_TOPSTORIES_SECTION_PROPS} {...props} />); + } + + it("should send impression with the right stats when the page loads", () => { + const dispatch = sinon.spy(); + renderSection({ dispatch }); + + assert.calledOnce(dispatch); + + const [action] = dispatch.firstCall.args; + assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS); + assert.equal(action.data.source, "TOP_STORIES"); + assert.deepEqual(action.data.tiles, [{ id: 1 }, { id: 2 }]); + }); + it("should not send impression stats if not configured", () => { + const dispatch = sinon.spy(); + const props = Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + shouldSendImpressionStats: false, + dispatch, + }); + renderSection(props); + assert.notCalled(dispatch); + }); + it("should not send impression stats if the section is collapsed", () => { + const dispatch = sinon.spy(); + const props = Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + pref: { collapsed: true }, + }); + renderSection(props); + assert.notCalled(dispatch); + }); + it("should send 1 impression when the page becomes visibile after loading", () => { + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }; + + renderSection(props); + + // Was the event listener added? + assert.calledWith(props.document.addEventListener, "visibilitychange"); + + // Make sure dispatch wasn't called yet + assert.notCalled(props.dispatch); + + // Simulate a visibilityChange event + const [, listener] = props.document.addEventListener.firstCall.args; + props.document.visibilityState = "visible"; + listener(); + + // Did we actually dispatch an event? + assert.calledOnce(props.dispatch); + assert.equal( + props.dispatch.firstCall.args[0].type, + at.TELEMETRY_IMPRESSION_STATS + ); + + // Did we remove the event listener? + assert.calledWith( + props.document.removeEventListener, + "visibilitychange", + listener + ); + }); + it("should remove visibility change listener when section is removed", () => { + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + }; + + const section = renderSection(props); + assert.calledWith(props.document.addEventListener, "visibilitychange"); + const [, listener] = props.document.addEventListener.firstCall.args; + + section.unmount(); + assert.calledWith( + props.document.removeEventListener, + "visibilitychange", + listener + ); + }); + it("should send an impression if props are updated and props.rows are different", () => { + const props = { dispatch: sinon.spy() }; + wrapper = renderSection(props); + props.dispatch.resetHistory(); + + // New rows + wrapper.setProps( + Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + rows: [{ guid: 123 }], + }) + ); + + assert.calledOnce(props.dispatch); + }); + it("should not send an impression if props are updated but props.rows are the same", () => { + const props = { dispatch: sinon.spy() }; + wrapper = renderSection(props); + props.dispatch.resetHistory(); + + // Only update the disclaimer prop + wrapper.setProps( + Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + disclaimer: { id: "bar" }, + }) + ); + + assert.notCalled(props.dispatch); + }); + it("should not send an impression if props are updated and props.rows are the same but section is collapsed", () => { + const props = { dispatch: sinon.spy() }; + wrapper = renderSection(props); + props.dispatch.resetHistory(); + + // New rows and collapsed + wrapper.setProps( + Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + rows: [{ guid: 123 }], + pref: { collapsed: true }, + }) + ); + + assert.notCalled(props.dispatch); + + // Expand the section. Now the impression stats should be sent + wrapper.setProps( + Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + rows: [{ guid: 123 }], + pref: { collapsed: false }, + }) + ); + + assert.calledOnce(props.dispatch); + }); + it("should not send an impression if props are updated but GUIDs are the same", () => { + const props = { dispatch: sinon.spy() }; + wrapper = renderSection(props); + props.dispatch.resetHistory(); + + wrapper.setProps( + Object.assign({}, FAKE_TOPSTORIES_SECTION_PROPS, { + rows: [{ guid: 1 }, { guid: 2 }], + }) + ); + + assert.notCalled(props.dispatch); + }); + it("should only send the latest impression on a visibility change", () => { + const listeners = new Set(); + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: (ev, cb) => listeners.add(cb), + removeEventListener: (ev, cb) => listeners.delete(cb), + }, + }; + + wrapper = renderSection(props); + + // Update twice + wrapper.setProps(Object.assign({}, props, { rows: [{ guid: 123 }] })); + wrapper.setProps(Object.assign({}, props, { rows: [{ guid: 2432 }] })); + + assert.notCalled(props.dispatch); + + // Simulate listeners getting called + props.document.visibilityState = "visible"; + listeners.forEach(l => l()); + + // Make sure we only sent the latest event + assert.calledOnce(props.dispatch); + const [action] = props.dispatch.firstCall.args; + assert.deepEqual(action.data.tiles, [{ id: 2432 }]); + }); + }); + + describe("tab rehydrated", () => { + it("should fire NEW_TAB_REHYDRATED event", () => { + const dispatch = sinon.spy(); + const TOP_STORIES_SECTION = { + id: "topstories", + title: "TopStories", + pref: { collapsed: false }, + initialized: false, + rows: [{ guid: 1, link: "http://localhost", isDefault: true }], + topics: [], + read_more_endpoint: "http://localhost/read-more", + maxRows: 1, + eventSource: "TOP_STORIES", + }; + wrapper = shallow( + <Section + Pocket={{ waitingForSpoc: true, pocketCta: {} }} + {...TOP_STORIES_SECTION} + dispatch={dispatch} + /> + ); + assert.notCalled(dispatch); + + wrapper.setProps({ initialized: true }); + + assert.calledOnce(dispatch); + const [action] = dispatch.firstCall.args; + assert.equal("NEW_TAB_REHYDRATED", action.type); + }); + }); + + describe("#numRows", () => { + it("should return maxRows if there is no rowsPref set", () => { + delete FAKE_SECTION.rowsPref; + wrapper = mountSectionIntlWithProps(FAKE_SECTION); + assert.equal( + wrapper.find(Section).instance().numRows, + FAKE_SECTION.maxRows + ); + }); + + it("should return number of rows set in Pref if rowsPref is set", () => { + const numRows = 2; + Object.assign(FAKE_SECTION, { + rowsPref: "section.rows", + maxRows: 4, + Prefs: { values: { "section.rows": numRows } }, + }); + wrapper = mountSectionWithProps(FAKE_SECTION); + assert.equal(wrapper.find(Section).instance().numRows, numRows); + }); + + it("should return number of rows set in Pref even if higher than maxRows value", () => { + const numRows = 10; + Object.assign(FAKE_SECTION, { + rowsPref: "section.rows", + maxRows: 4, + Prefs: { values: { "section.rows": numRows } }, + }); + wrapper = mountSectionWithProps(FAKE_SECTION); + assert.equal(wrapper.find(Section).instance().numRows, numRows); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx new file mode 100644 index 0000000000..4009909c81 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/TopSites.test.jsx @@ -0,0 +1,1919 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { MIN_RICH_FAVICON_SIZE } from "content-src/components/TopSites/TopSitesConstants"; +import { + TOP_SITES_DEFAULT_ROWS, + TOP_SITES_MAX_SITES_PER_ROW, +} from "common/Reducers.sys.mjs"; +import { + TopSite, + TopSiteLink, + _TopSiteList as TopSiteList, + TopSitePlaceholder, +} from "content-src/components/TopSites/TopSite"; +import { + INTERSECTION_RATIO, + TopSiteImpressionWrapper, +} from "content-src/components/TopSites/TopSiteImpressionWrapper"; +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { mount, shallow } from "enzyme"; +import { TopSiteForm } from "content-src/components/TopSites/TopSiteForm"; +import { TopSiteFormInput } from "content-src/components/TopSites/TopSiteFormInput"; +import { _TopSites as TopSites } from "content-src/components/TopSites/TopSites"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; + +const perfSvc = { + mark() {}, + getMostRecentAbsMarkStartByName() {}, +}; + +const DEFAULT_PROPS = { + Prefs: { values: { featureConfig: {} } }, + TopSites: { initialized: true, rows: [] }, + TopSitesRows: TOP_SITES_DEFAULT_ROWS, + topSiteIconType: () => "no_image", + dispatch() {}, + perfSvc, +}; + +const DEFAULT_BLOB_URL = "blob://test"; + +describe("<TopSites>", () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render a TopSites element", () => { + const wrapper = shallow(<TopSites {...DEFAULT_PROPS} />); + assert.ok(wrapper.exists()); + }); + describe("#_dispatchTopSitesStats", () => { + let globals; + let wrapper; + let dispatchStatsSpy; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox.stub(DEFAULT_PROPS, "dispatch"); + wrapper = shallow(<TopSites {...DEFAULT_PROPS} />, { + disableLifecycleMethods: true, + }); + dispatchStatsSpy = sandbox.spy( + wrapper.instance(), + "_dispatchTopSitesStats" + ); + }); + afterEach(() => { + globals.restore(); + sandbox.restore(); + }); + it("should call _dispatchTopSitesStats on componentDidMount", () => { + wrapper.instance().componentDidMount(); + + assert.calledOnce(dispatchStatsSpy); + }); + it("should call _dispatchTopSitesStats on componentDidUpdate", () => { + wrapper.instance().componentDidUpdate(); + + assert.calledOnce(dispatchStatsSpy); + }); + it("should dispatch SAVE_SESSION_PERF_DATA", () => { + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count TopSite images - just screenshot", () => { + const rows = [{ screenshot: true }]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 1, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count TopSite images - custom_screenshot", () => { + const rows = [{ customScreenshotURL: true }]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 1, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count TopSite images - rich_icon", () => { + const rows = [{ faviconSize: MIN_RICH_FAVICON_SIZE }]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 1, + no_image: 0, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count TopSite images - tippytop", () => { + const rows = [ + { tippyTopIcon: "foo" }, + { faviconRef: "tippytop" }, + { faviconRef: "foobar" }, + ]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 2, + rich_icon: 0, + no_image: 1, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count TopSite images - no image", () => { + const rows = [{}]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 1, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count pinned Top Sites", () => { + const rows = [ + { isPinned: true }, + { isPinned: false }, + { isPinned: true }, + ]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 3, + }, + topsites_pinned: 2, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should correctly count search shortcut Top Sites", () => { + const rows = [{ searchTopSite: true }, { searchTopSite: true }]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 2, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 2, + }, + }) + ); + }); + it("should only count visible top sites on wide layout", () => { + globals.set("matchMedia", () => ({ matches: true })); + const rows = [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + ]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + + wrapper.instance()._dispatchTopSitesStats(); + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 8, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + it("should only count visible top sites on normal layout", () => { + globals.set("matchMedia", () => ({ matches: false })); + const rows = [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + ]; + sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows); + wrapper.instance()._dispatchTopSitesStats(); + assert.calledOnce(DEFAULT_PROPS.dispatch); + assert.calledWithExactly( + DEFAULT_PROPS.dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 6, + }, + topsites_pinned: 0, + topsites_search_shortcuts: 0, + }, + }) + ); + }); + }); +}); + +describe("<TopSiteLink>", () => { + let globals; + let link; + let url; + beforeEach(() => { + globals = new GlobalOverrider(); + url = { + createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL), + revokeObjectURL: globals.sandbox.spy(), + }; + globals.set("URL", url); + link = { url: "https://foo.com", screenshot: "foo.jpg", hostname: "foo" }; + }); + afterEach(() => globals.restore()); + it("should add the right url", () => { + link.url = "https://www.foobar.org"; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.propertyVal( + wrapper.find("a").props(), + "href", + "https://www.foobar.org" + ); + }); + it("should not add the url to the href if it a search shortcut", () => { + link.searchTopSite = true; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.isUndefined(wrapper.find("a").props().href); + }); + it("should have rtl direction automatically set for text", () => { + const wrapper = shallow(<TopSiteLink link={link} />); + + assert.isTrue(!!wrapper.find("[dir='auto']").length); + }); + it("should render a title", () => { + const wrapper = shallow(<TopSiteLink link={link} title="foobar" />); + const titleEl = wrapper.find(".title"); + + assert.equal(titleEl.text(), "foobar"); + }); + it("should have only the title as the text of the link", () => { + const wrapper = shallow(<TopSiteLink link={link} title="foobar" />); + + assert.equal(wrapper.find("a").text(), "foobar"); + }); + it("should render the pin icon for pinned links", () => { + link.isPinned = true; + link.pinnedIndex = 7; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.equal(wrapper.find(".icon-pin-small").length, 1); + }); + it("should not render the pin icon for non pinned links", () => { + link.isPinned = false; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.equal(wrapper.find(".icon-pin-small").length, 0); + }); + it("should render the first letter of the title as a fallback for missing icons", () => { + const wrapper = shallow(<TopSiteLink link={link} title={"foo"} />); + assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f"); + }); + it("should render the tippy top icon if provided and not a small icon", () => { + link.tippyTopIcon = "foo.png"; + link.backgroundColor = "#FFFFFF"; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.lengthOf(wrapper.find(".screenshot"), 0); + assert.lengthOf(wrapper.find(".default-icon"), 0); + const tippyTop = wrapper.find(".rich-icon"); + assert.propertyVal( + tippyTop.props().style, + "backgroundImage", + "url(foo.png)" + ); + assert.propertyVal(tippyTop.props().style, "backgroundColor", "#FFFFFF"); + }); + it("should render a rich icon if provided and not a small icon", () => { + link.favicon = "foo.png"; + link.faviconSize = 196; + link.backgroundColor = "#FFFFFF"; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.lengthOf(wrapper.find(".screenshot"), 0); + assert.lengthOf(wrapper.find(".default-icon"), 0); + const richIcon = wrapper.find(".rich-icon"); + assert.propertyVal( + richIcon.props().style, + "backgroundImage", + "url(foo.png)" + ); + assert.propertyVal(richIcon.props().style, "backgroundColor", "#FFFFFF"); + }); + it("should not render a rich icon if it is smaller than 96x96", () => { + link.favicon = "foo.png"; + link.faviconSize = 48; + link.backgroundColor = "#FFFFFF"; + const wrapper = shallow(<TopSiteLink link={link} />); + assert.lengthOf(wrapper.find(".default-icon"), 1); + assert.equal(wrapper.find(".rich-icon").length, 0); + }); + it("should apply just the default class name to the outer link if props.className is falsey", () => { + const wrapper = shallow(<TopSiteLink className={false} />); + assert.ok(wrapper.find("li").hasClass("top-site-outer")); + }); + it("should add props.className to the outer link element", () => { + const wrapper = shallow(<TopSiteLink className="foo bar" />); + assert.ok(wrapper.find("li").hasClass("top-site-outer foo bar")); + }); + describe("#_allowDrop", () => { + let wrapper; + let event; + beforeEach(() => { + event = { + dataTransfer: { + types: ["text/topsite-index"], + }, + }; + wrapper = shallow( + <TopSiteLink isDraggable={true} onDragEvent={() => {}} /> + ); + }); + it("should be droppable for basic case", () => { + const result = wrapper.instance()._allowDrop(event); + assert.isTrue(result); + }); + it("should not be droppable for sponsored_position", () => { + wrapper.setProps({ link: { sponsored_position: 1 } }); + const result = wrapper.instance()._allowDrop(event); + assert.isFalse(result); + }); + it("should not be droppable for link.type", () => { + wrapper.setProps({ link: { type: "SPOC" } }); + const result = wrapper.instance()._allowDrop(event); + assert.isFalse(result); + }); + }); + describe("#onDragEvent", () => { + let simulate; + let wrapper; + beforeEach(() => { + wrapper = shallow( + <TopSiteLink isDraggable={true} onDragEvent={() => {}} /> + ); + simulate = type => { + const event = { + dataTransfer: { setData() {}, types: { includes() {} } }, + preventDefault() { + this.prevented = true; + }, + target: { blur() {} }, + type, + }; + wrapper.simulate(type, event); + return event; + }; + }); + it("should allow clicks without dragging", () => { + simulate("mousedown"); + simulate("mouseup"); + + const event = simulate("click"); + + assert.notOk(event.prevented); + }); + it("should prevent clicks after dragging", () => { + simulate("mousedown"); + simulate("dragstart"); + simulate("dragenter"); + simulate("drop"); + simulate("dragend"); + simulate("mouseup"); + + const event = simulate("click"); + + assert.ok(event.prevented); + }); + it("should allow clicks after dragging then clicking", () => { + simulate("mousedown"); + simulate("dragstart"); + simulate("dragenter"); + simulate("drop"); + simulate("dragend"); + simulate("mouseup"); + simulate("click"); + + simulate("mousedown"); + simulate("mouseup"); + + const event = simulate("click"); + + assert.notOk(event.prevented); + }); + it("should prevent dragging with sponsored_position from dragstart", () => { + const preventDefault = sinon.stub(); + const blur = sinon.stub(); + wrapper.setProps({ link: { sponsored_position: 1 } }); + wrapper.instance().onDragEvent({ + type: "dragstart", + preventDefault, + target: { blur }, + }); + assert.calledOnce(preventDefault); + assert.calledOnce(blur); + assert.isUndefined(wrapper.instance().dragged); + }); + it("should prevent dragging with link.shim from dragstart", () => { + const preventDefault = sinon.stub(); + const blur = sinon.stub(); + wrapper.setProps({ link: { type: "SPOC" } }); + wrapper.instance().onDragEvent({ + type: "dragstart", + preventDefault, + target: { blur }, + }); + assert.calledOnce(preventDefault); + assert.calledOnce(blur); + assert.isUndefined(wrapper.instance().dragged); + }); + }); + + describe("#generateColor", () => { + let colors; + beforeEach(() => { + colors = "#0090ED,#FF4F5F,#2AC3A2"; + }); + + it("should generate a random color but always pick the same color for the same string", async () => { + let wrapper = shallow( + <TopSiteLink colors={colors} title={"food"} link={link} /> + ); + + assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f"); + assert.equal( + wrapper.find(".icon-wrapper").prop("style").backgroundColor, + colors.split(",")[1] + ); + assert.ok(true); + }); + + it("should generate a different random color", async () => { + let wrapper = shallow( + <TopSiteLink colors={colors} title={"fam"} link={link} /> + ); + + assert.equal( + wrapper.find(".icon-wrapper").prop("style").backgroundColor, + colors.split(",")[2] + ); + assert.ok(true); + }); + + it("should generate a third random color", async () => { + let wrapper = shallow(<TopSiteLink colors={colors} title={"foo"} />); + + assert.equal(wrapper.find(".icon-wrapper").prop("data-fallback"), "f"); + assert.equal( + wrapper.find(".icon-wrapper").prop("style").backgroundColor, + colors.split(",")[0] + ); + assert.ok(true); + }); + }); +}); + +describe("<TopSite>", () => { + let link; + beforeEach(() => { + link = { url: "https://foo.com", screenshot: "foo.jpg", hostname: "foo" }; + }); + + // Build IntersectionObserver class with the arg `entries` for the intersect callback. + function buildIntersectionObserver(entries) { + return class { + constructor(callback) { + this.callback = callback; + } + + observe() { + this.callback(entries); + } + + unobserve() {} + }; + } + + it("should render a TopSite", () => { + const wrapper = shallow(<TopSite link={link} />); + assert.ok(wrapper.exists()); + }); + + it("should render a shortened title based off the url", () => { + link.url = "https://www.foobar.org"; + link.hostname = "foobar"; + link.eTLD = "org"; + const wrapper = shallow(<TopSite link={link} />); + + assert.equal(wrapper.find(TopSiteLink).props().title, "foobar"); + }); + + it("should parse args for fluent correctly", () => { + const title = '"fluent"'; + link.hostname = title; + + const wrapper = mount(<TopSite link={link} />); + const button = wrapper.find( + "button[data-l10n-id='newtab-menu-content-tooltip']" + ); + assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title })); + }); + + it("should have .active class, on top-site-outer if context menu is open", () => { + const wrapper = shallow(<TopSite link={link} index={1} activeIndex={1} />); + wrapper.setState({ showContextMenu: true }); + + assert.equal(wrapper.find(TopSiteLink).props().className.trim(), "active"); + }); + it("should not add .active class, on top-site-outer if context menu is closed", () => { + const wrapper = shallow(<TopSite link={link} index={1} />); + wrapper.setState({ showContextMenu: false, activeTile: 1 }); + assert.equal(wrapper.find(TopSiteLink).props().className, ""); + }); + it("should render a context menu button", () => { + const wrapper = shallow(<TopSite link={link} />); + assert.equal(wrapper.find(ContextMenuButton).length, 1); + }); + it("should render a link menu", () => { + const wrapper = shallow(<TopSite link={link} />); + assert.equal(wrapper.find(LinkMenu).length, 1); + }); + it("should pass onUpdate, site, options, and index to LinkMenu", () => { + const wrapper = shallow(<TopSite link={link} />); + const linkMenuProps = wrapper.find(LinkMenu).props(); + ["onUpdate", "site", "index", "options"].forEach(prop => + assert.property(linkMenuProps, prop) + ); + }); + it("should pass through the correct menu options to LinkMenu", () => { + const wrapper = shallow(<TopSite link={link} />); + const linkMenuProps = wrapper.find(LinkMenu).props(); + assert.deepEqual(linkMenuProps.options, [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", + ]); + }); + it("should record impressions for visible organic Top Sites", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <TopSite + link={link} + index={3} + dispatch={dispatch} + IntersectionObserver={buildIntersectionObserver([ + { + isIntersecting: true, + intersectionRatio: INTERSECTION_RATIO, + }, + ])} + document={{ + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }} + /> + ); + const linkWrapper = wrapper.find(TopSiteLink).dive(); + assert.ok(linkWrapper.exists()); + const impressionWrapper = linkWrapper.find(TopSiteImpressionWrapper).dive(); + assert.ok(impressionWrapper.exists()); + + assert.calledOnce(dispatch); + + let [action] = dispatch.firstCall.args; + assert.equal(action.type, at.TOP_SITES_ORGANIC_IMPRESSION_STATS); + + assert.propertyVal(action.data, "type", "impression"); + assert.propertyVal(action.data, "source", "newtab"); + assert.propertyVal(action.data, "position", 3); + }); + it("should record impressions for visible sponsored Top Sites", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <TopSite + link={Object.assign({}, link, { + sponsored_position: 2, + sponsored_tile_id: 12345, + sponsored_impression_url: "http://impression.example.com/", + })} + index={3} + dispatch={dispatch} + IntersectionObserver={buildIntersectionObserver([ + { + isIntersecting: true, + intersectionRatio: INTERSECTION_RATIO, + }, + ])} + document={{ + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }} + /> + ); + const linkWrapper = wrapper.find(TopSiteLink).dive(); + assert.ok(linkWrapper.exists()); + const impressionWrapper = linkWrapper.find(TopSiteImpressionWrapper).dive(); + assert.ok(impressionWrapper.exists()); + + assert.calledOnce(dispatch); + + let [action] = dispatch.firstCall.args; + assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); + + assert.propertyVal(action.data, "type", "impression"); + assert.propertyVal(action.data, "tile_id", 12345); + assert.propertyVal(action.data, "source", "newtab"); + assert.propertyVal(action.data, "position", 3); + assert.propertyVal( + action.data, + "reporting_url", + "http://impression.example.com/" + ); + assert.propertyVal(action.data, "advertiser", "foo"); + }); + + describe("#onLinkClick", () => { + it("should call dispatch when the link is clicked", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <TopSite link={link} index={3} dispatch={dispatch} /> + ); + + wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} }); + + let [action] = dispatch.firstCall.args; + assert.isUserEventAction(action); + + assert.propertyVal(action.data, "event", "CLICK"); + assert.propertyVal(action.data, "source", "TOP_SITES"); + assert.propertyVal(action.data, "action_position", 3); + + [action] = dispatch.secondCall.args; + assert.propertyVal(action, "type", at.OPEN_LINK); + + // Organic Top Site click event. + [action] = dispatch.thirdCall.args; + assert.equal(action.type, at.TOP_SITES_ORGANIC_IMPRESSION_STATS); + + assert.propertyVal(action.data, "type", "click"); + assert.propertyVal(action.data, "source", "newtab"); + assert.propertyVal(action.data, "position", 3); + }); + it("should dispatch a UserEventAction with the right data", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <TopSite + link={Object.assign({}, link, { + iconType: "rich_icon", + isPinned: true, + })} + index={3} + dispatch={dispatch} + /> + ); + + wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} }); + + const [action] = dispatch.firstCall.args; + assert.isUserEventAction(action); + + assert.propertyVal(action.data, "event", "CLICK"); + assert.propertyVal(action.data, "source", "TOP_SITES"); + assert.propertyVal(action.data, "action_position", 3); + assert.propertyVal(action.data.value, "card_type", "pinned"); + assert.propertyVal(action.data.value, "icon_type", "rich_icon"); + }); + it("should dispatch a UserEventAction with the right data for search top site", () => { + const dispatch = sinon.stub(); + const siteInfo = { + iconType: "tippytop", + isPinned: true, + searchTopSite: true, + hostname: "google", + label: "@google", + }; + const wrapper = shallow( + <TopSite + link={Object.assign({}, link, siteInfo)} + index={3} + dispatch={dispatch} + /> + ); + + wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} }); + + const [action] = dispatch.firstCall.args; + assert.isUserEventAction(action); + + assert.propertyVal(action.data, "event", "CLICK"); + assert.propertyVal(action.data, "source", "TOP_SITES"); + assert.propertyVal(action.data, "action_position", 3); + assert.propertyVal(action.data.value, "card_type", "search"); + assert.propertyVal(action.data.value, "icon_type", "tippytop"); + assert.propertyVal(action.data.value, "search_vendor", "google"); + }); + it("should dispatch a UserEventAction with the right data for SPOC top site", () => { + const dispatch = sinon.stub(); + const siteInfo = { + id: 1, + iconType: "custom_screenshot", + type: "SPOC", + pos: 1, + label: "test advertiser", + }; + const wrapper = shallow( + <TopSite + link={Object.assign({}, link, siteInfo)} + index={0} + dispatch={dispatch} + /> + ); + + wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} }); + + let [action] = dispatch.firstCall.args; + assert.isUserEventAction(action); + + assert.propertyVal(action.data, "event", "CLICK"); + assert.propertyVal(action.data, "source", "TOP_SITES"); + assert.propertyVal(action.data, "action_position", 0); + assert.propertyVal(action.data.value, "card_type", "spoc"); + assert.propertyVal(action.data.value, "icon_type", "custom_screenshot"); + + // Pocket SPOC click event. + [action] = dispatch.getCall(2).args; + assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS); + + assert.propertyVal(action.data, "click", 0); + assert.propertyVal(action.data, "source", "TOP_SITES"); + + // Topsite SPOC click event. + [action] = dispatch.getCall(3).args; + assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); + + assert.propertyVal(action.data, "type", "click"); + assert.propertyVal(action.data, "tile_id", 1); + assert.propertyVal(action.data, "source", "newtab"); + assert.propertyVal(action.data, "position", 1); + assert.propertyVal(action.data, "advertiser", "test advertiser"); + }); + it("should dispatch OPEN_LINK with the right data", () => { + const dispatch = sinon.stub(); + const wrapper = shallow( + <TopSite + link={Object.assign({}, link, { typedBonus: true })} + index={3} + dispatch={dispatch} + /> + ); + + wrapper.find(TopSiteLink).simulate("click", { preventDefault() {} }); + + const [action] = dispatch.secondCall.args; + assert.propertyVal(action, "type", at.OPEN_LINK); + assert.propertyVal(action.data, "typedBonus", true); + }); + }); +}); + +describe("<TopSiteForm>", () => { + let wrapper; + let sandbox; + + function setup(props = {}) { + sandbox = sinon.createSandbox(); + const customProps = Object.assign( + {}, + { onClose: sandbox.spy(), dispatch: sandbox.spy() }, + props + ); + wrapper = mount(<TopSiteForm {...customProps} />); + } + + describe("validateForm", () => { + beforeEach(() => setup({ site: { url: "http://foo" } })); + + it("should return true for a correct URL", () => { + wrapper.setState({ url: "foo" }); + + assert.isTrue(wrapper.instance().validateForm()); + }); + + it("should return false for a incorrect URL", () => { + wrapper.setState({ url: " " }); + + assert.isNull(wrapper.instance().validateForm()); + assert.isTrue(wrapper.state().validationError); + }); + + it("should return true for a correct custom screenshot URL", () => { + wrapper.setState({ customScreenshotUrl: "foo" }); + + assert.isTrue(wrapper.instance().validateForm()); + }); + + it("should return false for a incorrect custom screenshot URL", () => { + wrapper.setState({ customScreenshotUrl: " " }); + + assert.isNull(wrapper.instance().validateForm()); + }); + + it("should return true for an empty custom screenshot URL", () => { + wrapper.setState({ customScreenshotUrl: "" }); + + assert.isTrue(wrapper.instance().validateForm()); + }); + + it("should return false for file: protocol", () => { + wrapper.setState({ customScreenshotUrl: "file:///C:/Users/foo" }); + + assert.isFalse(wrapper.instance().validateForm()); + }); + }); + + describe("#previewButton", () => { + beforeEach(() => + setup({ + site: { customScreenshotURL: "http://foo.com" }, + previewResponse: null, + }) + ); + + it("should render the preview button on invalid urls", () => { + assert.equal(0, wrapper.find(".preview").length); + + wrapper.setState({ customScreenshotUrl: " " }); + + assert.equal(1, wrapper.find(".preview").length); + }); + + it("should render the preview button when input value updated", () => { + assert.equal(0, wrapper.find(".preview").length); + + wrapper.setState({ + customScreenshotUrl: "http://baz.com", + screenshotPreview: null, + }); + + assert.equal(1, wrapper.find(".preview").length); + }); + }); + + describe("preview request", () => { + beforeEach(() => { + setup({ + site: { customScreenshotURL: "http://foo.com", url: "http://foo.com" }, + previewResponse: null, + }); + }); + + it("shouldn't dispatch a request for invalid urls", () => { + wrapper.setState({ customScreenshotUrl: " ", url: "foo" }); + + wrapper.find(".preview").simulate("click"); + + assert.notCalled(wrapper.props().dispatch); + }); + + it("should dispatch a PREVIEW_REQUEST", () => { + wrapper.setState({ customScreenshotUrl: "screenshot" }); + wrapper.find(".preview").simulate("submit"); + + assert.calledTwice(wrapper.props().dispatch); + assert.calledWith( + wrapper.props().dispatch, + ac.AlsoToMain({ + type: at.PREVIEW_REQUEST, + data: { url: "http://screenshot" }, + }) + ); + assert.calledWith( + wrapper.props().dispatch, + ac.UserEvent({ + event: "PREVIEW_REQUEST", + source: "TOP_SITES", + }) + ); + }); + }); + + describe("#TopSiteLink", () => { + beforeEach(() => { + setup(); + }); + + it("should display a TopSiteLink preview", () => { + assert.equal(wrapper.find(TopSiteLink).length, 1); + }); + + it("should display an icon for tippyTop sites", () => { + wrapper.setProps({ site: { tippyTopIcon: "bar" } }); + + assert.equal( + wrapper.find(".top-site-icon").getDOMNode().style["background-image"], + 'url("bar")' + ); + }); + + it("should not display a preview screenshot", () => { + wrapper.setProps({ previewResponse: "foo", previewUrl: "foo" }); + + assert.lengthOf(wrapper.find(".screenshot"), 0); + }); + + it("should not render any icon on error", () => { + wrapper.setProps({ previewResponse: "" }); + + assert.equal(wrapper.find(".top-site-icon").length, 0); + }); + + it("should render the search icon when searchTopSite is true", () => { + wrapper.setProps({ site: { tippyTopIcon: "bar", searchTopSite: true } }); + + assert.equal( + wrapper.find(".rich-icon").getDOMNode().style["background-image"], + 'url("bar")' + ); + assert.isTrue(wrapper.find(".search-topsite").exists()); + }); + }); + + describe("#addMode", () => { + beforeEach(() => setup()); + + it("should render the component", () => { + assert.ok(wrapper.find(TopSiteForm).exists()); + }); + it("should have the correct header", () => { + assert.equal( + wrapper.findWhere( + n => + n.length && + n.prop("data-l10n-id") === "newtab-topsites-add-shortcut-header" + ).length, + 1 + ); + }); + it("should have the correct button text", () => { + assert.equal( + wrapper.findWhere( + n => + n.length && n.prop("data-l10n-id") === "newtab-topsites-save-button" + ).length, + 0 + ); + assert.equal( + wrapper.findWhere( + n => + n.length && n.prop("data-l10n-id") === "newtab-topsites-add-button" + ).length, + 1 + ); + }); + it("should not render a preview button", () => { + assert.equal(0, wrapper.find(".custom-image-input-container").length); + }); + it("should call onClose if Cancel button is clicked", () => { + wrapper.find(".cancel").simulate("click"); + assert.calledOnce(wrapper.instance().props.onClose); + }); + it("should set validationError if url is empty", () => { + assert.equal(wrapper.state().validationError, false); + wrapper.find(".done").simulate("submit"); + assert.equal(wrapper.state().validationError, true); + }); + it("should set validationError if url is invalid", () => { + wrapper.setState({ url: "not valid" }); + assert.equal(wrapper.state().validationError, false); + wrapper.find(".done").simulate("submit"); + assert.equal(wrapper.state().validationError, true); + }); + it("should call onClose and dispatch with right args if URL is valid", () => { + wrapper.setState({ url: "valid.com", label: "a label" }); + wrapper.find(".done").simulate("submit"); + assert.calledOnce(wrapper.instance().props.onClose); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + site: { label: "a label", url: "http://valid.com" }, + index: -1, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + action_position: -1, + source: "TOP_SITES", + event: "TOP_SITES_EDIT", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TELEMETRY_USER_EVENT, + }); + }); + it("should not pass empty string label in dispatch data", () => { + wrapper.setState({ url: "valid.com", label: "" }); + wrapper.find(".done").simulate("submit"); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { site: { url: "http://valid.com" }, index: -1 }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + }); + it("should open the custom screenshot input", () => { + assert.isFalse(wrapper.state().showCustomScreenshotForm); + + wrapper.find(A11yLinkButton).simulate("click"); + + assert.isTrue(wrapper.state().showCustomScreenshotForm); + }); + }); + + describe("edit existing Topsite", () => { + beforeEach(() => + setup({ + site: { + url: "https://foo.bar", + label: "baz", + customScreenshotURL: "http://foo", + }, + index: 7, + }) + ); + + it("should render the component", () => { + assert.ok(wrapper.find(TopSiteForm).exists()); + }); + it("should have the correct header", () => { + assert.equal( + wrapper.findWhere( + n => n.prop("data-l10n-id") === "newtab-topsites-edit-shortcut-header" + ).length, + 1 + ); + }); + it("should have the correct button text", () => { + assert.equal( + wrapper.findWhere( + n => n.prop("data-l10n-id") === "newtab-topsites-add-button" + ).length, + 0 + ); + assert.equal( + wrapper.findWhere( + n => n.prop("data-l10n-id") === "newtab-topsites-save-button" + ).length, + 1 + ); + }); + it("should call onClose if Cancel button is clicked", () => { + wrapper.find(".cancel").simulate("click"); + assert.calledOnce(wrapper.instance().props.onClose); + }); + it("should show error and not call onClose or dispatch if URL is empty", () => { + wrapper.setState({ url: "" }); + assert.equal(wrapper.state().validationError, false); + wrapper.find(".done").simulate("submit"); + assert.equal(wrapper.state().validationError, true); + assert.notCalled(wrapper.instance().props.onClose); + assert.notCalled(wrapper.instance().props.dispatch); + }); + it("should show error and not call onClose or dispatch if URL is invalid", () => { + wrapper.setState({ url: "not valid" }); + assert.equal(wrapper.state().validationError, false); + wrapper.find(".done").simulate("submit"); + assert.equal(wrapper.state().validationError, true); + assert.notCalled(wrapper.instance().props.onClose); + assert.notCalled(wrapper.instance().props.dispatch); + }); + it("should call onClose and dispatch with right args if URL is valid", () => { + wrapper.find(".done").simulate("submit"); + assert.calledOnce(wrapper.instance().props.onClose); + assert.calledTwice(wrapper.instance().props.dispatch); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + site: { + label: "baz", + url: "https://foo.bar", + customScreenshotURL: "http://foo", + }, + index: 7, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + action_position: 7, + source: "TOP_SITES", + event: "TOP_SITES_EDIT", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TELEMETRY_USER_EVENT, + }); + }); + it("should set customScreenshotURL to null if it was removed", () => { + wrapper.setState({ customScreenshotUrl: "" }); + + wrapper.find(".done").simulate("submit"); + + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + site: { + label: "baz", + url: "https://foo.bar", + customScreenshotURL: null, + }, + index: 7, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + }); + it("should call onClose and dispatch with right args if URL is valid (negative index)", () => { + wrapper.setProps({ index: -1 }); + wrapper.find(".done").simulate("submit"); + assert.calledOnce(wrapper.instance().props.onClose); + assert.calledTwice(wrapper.instance().props.dispatch); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + site: { + label: "baz", + url: "https://foo.bar", + customScreenshotURL: "http://foo", + }, + index: -1, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + }); + it("should not pass empty string label in dispatch data", () => { + wrapper.setState({ label: "" }); + wrapper.find(".done").simulate("submit"); + assert.calledWith(wrapper.instance().props.dispatch, { + data: { + site: { url: "https://foo.bar", customScreenshotURL: "http://foo" }, + index: 7, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: at.TOP_SITES_PIN, + }); + }); + it("should render the save button if custom screenshot request finished", () => { + wrapper.setState({ + customScreenshotUrl: "foo", + screenshotPreview: "custom", + }); + assert.equal(0, wrapper.find(".preview").length); + assert.equal(1, wrapper.find(".done").length); + }); + it("should render the save button if custom screenshot url was cleared", () => { + wrapper.setState({ customScreenshotUrl: "" }); + wrapper.setProps({ site: { customScreenshotURL: "foo" } }); + assert.equal(0, wrapper.find(".preview").length); + assert.equal(1, wrapper.find(".done").length); + }); + }); + + describe("#previewMode", () => { + beforeEach(() => setup({ previewResponse: null })); + + it("should transition from save to preview", () => { + wrapper.setProps({ + site: { url: "https://foo.bar", customScreenshotURL: "baz" }, + index: 7, + }); + + assert.equal( + wrapper.findWhere( + n => + n.length && n.prop("data-l10n-id") === "newtab-topsites-save-button" + ).length, + 1 + ); + + wrapper.setState({ customScreenshotUrl: "foo" }); + + assert.equal( + wrapper.findWhere( + n => + n.length && + n.prop("data-l10n-id") === "newtab-topsites-preview-button" + ).length, + 1 + ); + }); + + it("should transition from add to preview", () => { + assert.equal( + wrapper.findWhere( + n => + n.length && n.prop("data-l10n-id") === "newtab-topsites-add-button" + ).length, + 1 + ); + + wrapper.setState({ customScreenshotUrl: "foo" }); + + assert.equal( + wrapper.findWhere( + n => + n.length && + n.prop("data-l10n-id") === "newtab-topsites-preview-button" + ).length, + 1 + ); + }); + }); + + describe("#validateUrl", () => { + it("should properly validate URLs", () => { + setup(); + assert.ok(wrapper.instance().validateUrl("mozilla.org")); + assert.ok(wrapper.instance().validateUrl("https://mozilla.org")); + assert.ok(wrapper.instance().validateUrl("http://mozilla.org")); + assert.ok( + wrapper + .instance() + .validateUrl( + "https://mozilla.invisionapp.com/d/main/#/projects/prototypes" + ) + ); + assert.ok(wrapper.instance().validateUrl("httpfoobar")); + assert.ok(wrapper.instance().validateUrl("httpsfoo.bar")); + assert.isNull(wrapper.instance().validateUrl("mozilla org")); + assert.isNull(wrapper.instance().validateUrl("")); + }); + }); + + describe("#cleanUrl", () => { + it("should properly prepend http:// to URLs when required", () => { + setup(); + assert.equal( + "http://mozilla.org", + wrapper.instance().cleanUrl("mozilla.org") + ); + assert.equal( + "http://https.org", + wrapper.instance().cleanUrl("https.org") + ); + assert.equal("http://httpcom", wrapper.instance().cleanUrl("httpcom")); + assert.equal( + "http://mozilla.org", + wrapper.instance().cleanUrl("http://mozilla.org") + ); + assert.equal( + "https://firefox.com", + wrapper.instance().cleanUrl("https://firefox.com") + ); + }); + }); +}); + +describe("<TopSiteList>", () => { + const APP = { isForStartupCache: false }; + + it("should render a TopSiteList element", () => { + const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />); + assert.ok(wrapper.exists()); + }); + it("should render a TopSite for each link with the right url", () => { + const rows = [{ url: "https://foo.com" }, { url: "https://bar.com" }]; + const wrapper = shallow( + <TopSiteList {...DEFAULT_PROPS} TopSites={{ rows }} App={{ APP }} /> + ); + const links = wrapper.find(TopSite); + assert.lengthOf(links, 2); + rows.forEach((row, i) => + assert.equal(links.get(i).props.link.url, row.url) + ); + }); + it("should slice the TopSite rows to the TopSitesRows pref", () => { + const rows = []; + for ( + let i = 0; + i < TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW + 3; + i++ + ) { + rows.push({ url: `https://foo${i}.com` }); + } + const wrapper = shallow( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={TOP_SITES_DEFAULT_ROWS} + App={{ APP }} + /> + ); + const links = wrapper.find(TopSite); + assert.lengthOf( + links, + TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW + ); + }); + it("should fill with placeholders if TopSites rows is less than TopSitesRows", () => { + const rows = [{ url: "https://foo.com" }, { url: "https://bar.com" }]; + const wrapper = shallow( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={1} + App={{ APP }} + /> + ); + assert.lengthOf(wrapper.find(TopSite), 2, "topSites"); + assert.lengthOf( + wrapper.find(TopSitePlaceholder), + TOP_SITES_MAX_SITES_PER_ROW - 2, + "placeholders" + ); + }); + it("should fill sponsored top sites with placeholders while rendering for startup cache", () => { + const rows = [ + { url: "https://sponsored01.com", sponsored_position: 1 }, + { url: "https://sponsored02.com", sponsored_position: 2 }, + { url: "https://sponsored03.com", type: "SPOC" }, + { url: "https://foo.com" }, + { url: "https://bar.com" }, + ]; + const wrapper = shallow( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={1} + App={{ isForStartupCache: true }} + /> + ); + assert.lengthOf(wrapper.find(TopSite), 2, "topSites"); + assert.lengthOf( + wrapper.find(TopSitePlaceholder), + TOP_SITES_MAX_SITES_PER_ROW - 2, + "placeholders" + ); + }); + it("should fill any holes in TopSites with placeholders", () => { + const rows = [{ url: "https://foo.com" }]; + rows[3] = { url: "https://bar.com" }; + const wrapper = shallow( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={1} + App={{ APP }} + /> + ); + assert.lengthOf(wrapper.find(TopSite), 2, "topSites"); + assert.lengthOf( + wrapper.find(TopSitePlaceholder), + TOP_SITES_MAX_SITES_PER_ROW - 2, + "placeholders" + ); + }); + it("should update state onDragStart and clear it onDragEnd", () => { + const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />); + const instance = wrapper.instance(); + const index = 7; + const link = { url: "https://foo.com" }; + const title = "foo"; + instance.onDragEvent({ type: "dragstart" }, index, link, title); + assert.equal(instance.state.draggedIndex, index); + assert.equal(instance.state.draggedSite, link); + assert.equal(instance.state.draggedTitle, title); + instance.onDragEvent({ type: "dragend" }); + assert.deepEqual(instance.state, TopSiteList.DEFAULT_STATE); + }); + it("should clear state when new props arrive after a drop", () => { + const site1 = { url: "https://foo.com" }; + const site2 = { url: "https://bar.com" }; + const rows = [site1, site2]; + const wrapper = shallow( + <TopSiteList {...DEFAULT_PROPS} TopSites={{ rows }} App={{ APP }} /> + ); + const instance = wrapper.instance(); + instance.setState({ + draggedIndex: 1, + draggedSite: site2, + draggedTitle: "bar", + topSitesPreview: [], + }); + wrapper.setProps({ TopSites: { rows: [site2, site1] } }); + assert.deepEqual(instance.state, TopSiteList.DEFAULT_STATE); + }); + it("should dispatch events on drop", () => { + const dispatch = sinon.spy(); + const wrapper = shallow( + <TopSiteList {...DEFAULT_PROPS} dispatch={dispatch} App={{ APP }} /> + ); + const instance = wrapper.instance(); + const index = 7; + const link = { url: "https://foo.com", customScreenshotURL: "foo" }; + const title = "foo"; + instance.onDragEvent({ type: "dragstart" }, index, link, title); + dispatch.resetHistory(); + instance.onDragEvent({ type: "drop" }, 3); + assert.calledTwice(dispatch); + assert.calledWith(dispatch, { + data: { + draggedFromIndex: 7, + index: 3, + site: { + label: "foo", + url: "https://foo.com", + customScreenshotURL: "foo", + }, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "TOP_SITES_INSERT", + }); + assert.calledWith(dispatch, { + data: { action_position: 3, event: "DROP", source: "TOP_SITES" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "TELEMETRY_USER_EVENT", + }); + }); + it("should make a topSitesPreview onDragEnter", () => { + const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} App={{ APP }} />); + const instance = wrapper.instance(); + const site = { url: "https://foo.com" }; + instance.setState({ + draggedIndex: 4, + draggedSite: site, + draggedTitle: "foo", + }); + const draggedSite = Object.assign({}, site, { + isPinned: true, + isDragged: true, + }); + instance.onDragEvent({ type: "dragenter" }, 2); + assert.ok(instance.state.topSitesPreview); + assert.deepEqual(instance.state.topSitesPreview[2], draggedSite); + }); + it("should _makeTopSitesPreview correctly", () => { + const site1 = { url: "https://foo.com" }; + const site2 = { url: "https://bar.com" }; + const site3 = { url: "https://baz.com" }; + const rows = [site1, site2, site3]; + let wrapper = shallow( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={1} + App={{ APP }} + /> + ); + let instance = wrapper.instance(); + instance.setState({ + draggedIndex: 0, + draggedSite: site1, + draggedTitle: "foo", + }); + let draggedSite = Object.assign({}, site1, { + isPinned: true, + isDragged: true, + }); + assert.deepEqual(instance._makeTopSitesPreview(1), [ + site2, + draggedSite, + site3, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(2), [ + site2, + site3, + draggedSite, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(3), [ + site2, + site3, + null, + draggedSite, + null, + null, + null, + null, + ]); + site2.isPinned = true; + assert.deepEqual(instance._makeTopSitesPreview(1), [ + site2, + draggedSite, + site3, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(2), [ + site3, + site2, + draggedSite, + null, + null, + null, + null, + null, + ]); + site3.isPinned = true; + assert.deepEqual(instance._makeTopSitesPreview(1), [ + site2, + draggedSite, + site3, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(2), [ + site2, + site3, + draggedSite, + null, + null, + null, + null, + null, + ]); + site2.isPinned = false; + assert.deepEqual(instance._makeTopSitesPreview(1), [ + site2, + draggedSite, + site3, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(2), [ + site2, + site3, + draggedSite, + null, + null, + null, + null, + null, + ]); + site3.isPinned = false; + instance.setState({ + draggedIndex: 1, + draggedSite: site2, + draggedTitle: "bar", + }); + draggedSite = Object.assign({}, site2, { isPinned: true, isDragged: true }); + assert.deepEqual(instance._makeTopSitesPreview(0), [ + draggedSite, + site1, + site3, + null, + null, + null, + null, + null, + ]); + assert.deepEqual(instance._makeTopSitesPreview(2), [ + site1, + site3, + draggedSite, + null, + null, + null, + null, + null, + ]); + site2.type = "SPOC"; + instance.setState({ + draggedIndex: 2, + draggedSite: site3, + draggedTitle: "baz", + }); + draggedSite = Object.assign({}, site3, { isPinned: true, isDragged: true }); + assert.deepEqual(instance._makeTopSitesPreview(0), [ + draggedSite, + site2, + site1, + null, + null, + null, + null, + null, + ]); + site2.type = ""; + site2.sponsored_position = 2; + instance.setState({ + draggedIndex: 2, + draggedSite: site3, + draggedTitle: "baz", + }); + draggedSite = Object.assign({}, site3, { isPinned: true, isDragged: true }); + assert.deepEqual(instance._makeTopSitesPreview(0), [ + draggedSite, + site2, + site1, + null, + null, + null, + null, + null, + ]); + }); + it("should add a className hide-for-narrow to sites after 6/row", () => { + const rows = []; + for (let i = 0; i < TOP_SITES_MAX_SITES_PER_ROW; i++) { + rows.push({ url: `https://foo${i}.com` }); + } + const wrapper = mount( + <TopSiteList + {...DEFAULT_PROPS} + TopSites={{ rows }} + TopSitesRows={1} + App={{ APP }} + /> + ); + assert.lengthOf(wrapper.find("li.hide-for-narrow"), 2); + }); +}); + +describe("TopSitePlaceholder", () => { + it("should dispatch a TOP_SITES_EDIT action when edit-button is clicked", () => { + const dispatch = sinon.spy(); + const wrapper = shallow( + <TopSitePlaceholder dispatch={dispatch} index={7} /> + ); + + wrapper.find(".edit-button").first().simulate("click"); + + assert.calledOnce(dispatch); + assert.calledWithExactly(dispatch, { + type: at.TOP_SITES_EDIT, + data: { index: 7 }, + }); + }); +}); + +describe("#TopSiteFormInput", () => { + let wrapper; + let onChangeStub; + + describe("no errors", () => { + beforeEach(() => { + onChangeStub = sinon.stub(); + + wrapper = mount( + <TopSiteFormInput + titleId="newtab-topsites-title-label" + placeholderId="newtab-topsites-title-input" + errorMessageId="newtab-topsites-url-validation" + onChange={onChangeStub} + value="foo" + /> + ); + }); + + it("should render the provided title", () => { + const title = wrapper.find("span"); + assert.propertyVal( + title.props(), + "data-l10n-id", + "newtab-topsites-title-label" + ); + }); + + it("should render the provided value", () => { + const input = wrapper.find("input"); + + assert.equal(input.getDOMNode().value, "foo"); + }); + + it("should render the clear button if cb is provided", () => { + assert.equal(wrapper.find(".icon-clear-input").length, 0); + + wrapper.setProps({ onClear: sinon.stub() }); + + assert.equal(wrapper.find(".icon-clear-input").length, 1); + }); + + it("should show the loading indicator", () => { + assert.equal(wrapper.find(".loading-container").length, 0); + + wrapper.setProps({ loading: true }); + + assert.equal(wrapper.find(".loading-container").length, 1); + }); + it("should disable the input when loading indicator is present", () => { + assert.isFalse(wrapper.find("input").getDOMNode().disabled); + + wrapper.setProps({ loading: true }); + + assert.isTrue(wrapper.find("input").getDOMNode().disabled); + }); + }); + + describe("with error", () => { + beforeEach(() => { + onChangeStub = sinon.stub(); + + wrapper = mount( + <TopSiteFormInput + titleId="newtab-topsites-title-label" + placeholderId="newtab-topsites-title-input" + onChange={onChangeStub} + validationError={true} + errorMessageId="newtab-topsites-url-validation" + value="foo" + /> + ); + }); + + it("should render the error message", () => { + assert.equal( + wrapper.findWhere( + n => n.prop("data-l10n-id") === "newtab-topsites-url-validation" + ).length, + 1 + ); + }); + + it("should reset the error state on value change", () => { + wrapper.find("input").simulate("change", { target: { value: "bar" } }); + + assert.isFalse(wrapper.state().validationError); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx new file mode 100644 index 0000000000..22c4e8192a --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/TopSites/SearchShortcutsForm.test.jsx @@ -0,0 +1,56 @@ +import { + SearchShortcutsForm, + SelectableSearchShortcut, +} from "content-src/components/TopSites/SearchShortcutsForm"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<SearchShortcutsForm>", () => { + let wrapper; + let sandbox; + let dispatchStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + dispatchStub = sandbox.stub(); + const defaultProps = { rows: [], searchShortcuts: [] }; + wrapper = shallow( + <SearchShortcutsForm TopSites={defaultProps} dispatch={dispatchStub} /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render", () => { + assert.ok(wrapper.exists()); + assert.ok(wrapper.find(".topsite-form").exists()); + }); + + it("should render SelectableSearchShortcut components", () => { + wrapper.setState({ shortcuts: [{}, {}] }); + + assert.lengthOf( + wrapper.find(".search-shortcuts-container div").children(), + 2 + ); + assert.equal( + wrapper.find(".search-shortcuts-container div").children().at(0).type(), + SelectableSearchShortcut + ); + }); + + it("should render SelectableSearchShortcut components", () => { + const onCloseStub = sandbox.stub(); + const fakeEvent = { preventDefault: sandbox.stub() }; + wrapper.setState({ shortcuts: [{}, {}] }); + wrapper.setProps({ onClose: onCloseStub }); + + wrapper.find(".done").simulate("click", fakeEvent); + + assert.calledOnce(dispatchStub); + assert.calledOnce(fakeEvent.preventDefault); + assert.calledOnce(onCloseStub); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx new file mode 100644 index 0000000000..79cb6ec7c5 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/TopSites/TopSiteImpressionWrapper.test.jsx @@ -0,0 +1,150 @@ +"use strict"; + +import { + TopSiteImpressionWrapper, + INTERSECTION_RATIO, +} from "content-src/components/TopSites/TopSiteImpressionWrapper"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<TopSiteImpressionWrapper>", () => { + const FullIntersectEntries = [ + { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO }, + ]; + const ZeroIntersectEntries = [ + { isIntersecting: false, intersectionRatio: 0 }, + ]; + const PartialIntersectEntries = [ + { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 }, + ]; + + // Build IntersectionObserver class with the arg `entries` for the intersect callback. + function buildIntersectionObserver(entries) { + return class { + constructor(callback) { + this.callback = callback; + } + + observe() { + this.callback(entries); + } + + unobserve() {} + }; + } + + const DEFAULT_PROPS = { + actionType: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + tile: { + tile_id: 1, + position: 1, + reporting_url: "https://test.reporting.com", + advertiser: "test_advertiser", + }, + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + document: { + visibilityState: "visible", + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }, + }; + + const InnerEl = () => <div>Inner Element</div>; + + function renderTopSiteImpressionWrapper(props = {}) { + return shallow( + <TopSiteImpressionWrapper {...DEFAULT_PROPS} {...props}> + <InnerEl /> + </TopSiteImpressionWrapper> + ); + } + + it("should render props.children", () => { + const wrapper = renderTopSiteImpressionWrapper(); + assert.ok(wrapper.contains(<InnerEl />)); + }); + it("should not send impression when the wrapped item is visbible but below the ratio", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries), + }; + renderTopSiteImpressionWrapper(props); + + assert.notCalled(dispatch); + }); + it("should send an impression when the page is visible and the wrapped item meets the visibility ratio", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(FullIntersectEntries), + }; + renderTopSiteImpressionWrapper(props); + + assert.calledOnce(dispatch); + + let [action] = dispatch.firstCall.args; + assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); + assert.deepEqual(action.data, { + type: "impression", + ...DEFAULT_PROPS.tile, + }); + }); + it("should send an impression when the wrapped item transiting from invisible to visible", () => { + const dispatch = sinon.spy(); + const props = { + dispatch, + IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries), + }; + const wrapper = renderTopSiteImpressionWrapper(props); + + assert.notCalled(dispatch); + + dispatch.resetHistory(); + wrapper.instance().impressionObserver.callback(FullIntersectEntries); + + // For the impression + assert.calledOnce(dispatch); + + const [action] = dispatch.firstCall.args; + assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS); + assert.deepEqual(action.data, { + type: "impression", + ...DEFAULT_PROPS.tile, + }); + }); + it("should remove visibility change listener when the wrapper is removed", () => { + const props = { + dispatch: sinon.spy(), + document: { + visibilityState: "hidden", + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }, + IntersectionObserver, + }; + + const wrapper = renderTopSiteImpressionWrapper(props); + assert.calledWith(props.document.addEventListener, "visibilitychange"); + const [, listener] = props.document.addEventListener.firstCall.args; + + wrapper.unmount(); + assert.calledWith( + props.document.removeEventListener, + "visibilitychange", + listener + ); + }); + it("should unobserve the intersection observer when the wrapper is removed", () => { + const IntersectionObserver = + buildIntersectionObserver(ZeroIntersectEntries); + const spy = sinon.spy(IntersectionObserver.prototype, "unobserve"); + const props = { dispatch: sinon.spy(), IntersectionObserver }; + + const wrapper = renderTopSiteImpressionWrapper(props); + wrapper.unmount(); + + assert.calledOnce(spy); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx b/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx new file mode 100644 index 0000000000..91d15c5d4e --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/Topics.test.jsx @@ -0,0 +1,22 @@ +import { Topic, Topics } from "content-src/components/Topics/Topics"; +import React from "react"; +import { shallow } from "enzyme"; + +describe("<Topics>", () => { + it("should render a Topics element", () => { + const wrapper = shallow(<Topics topics={[]} />); + assert.ok(wrapper.exists()); + }); + it("should render a Topic element for each topic with the right url", () => { + const data = [ + { name: "topic1", url: "https://topic1.com" }, + { name: "topic2", url: "https://topic2.com" }, + ]; + + const wrapper = shallow(<Topics topics={data} />); + + const topics = wrapper.find(Topic); + assert.lengthOf(topics, 2); + topics.forEach((topic, i) => assert.equal(topic.props().url, data[i].url)); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js b/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js new file mode 100644 index 0000000000..953fc60d79 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/components/addUtmParams.test.js @@ -0,0 +1,35 @@ +import { + addUtmParams, + BASE_PARAMS, +} from "content-src/asrouter/templates/FirstRun/addUtmParams"; + +describe("addUtmParams", () => { + it("should convert a string URL", () => { + const result = addUtmParams("https://foo.com", "foo"); + assert.equal(result.hostname, "foo.com"); + }); + it("should add all base params", () => { + assert.match( + addUtmParams(new URL("https://foo.com"), "foo").toString(), + /utm_source=activity-stream&utm_campaign=firstrun&utm_medium=referral/ + ); + }); + it("should allow updating base params utm values", () => { + BASE_PARAMS.utm_campaign = "firstrun-default"; + assert.match( + addUtmParams(new URL("https://foo.com"), "foo", "default").toString(), + /utm_source=activity-stream&utm_campaign=firstrun-default&utm_medium=referral/ + ); + }); + it("should add utm_term", () => { + const params = addUtmParams(new URL("https://foo.com"), "foo").searchParams; + assert.equal(params.get("utm_term"), "foo", "utm_term"); + }); + it("should not override the URL's existing utm param values", () => { + const url = new URL("https://foo.com/?utm_source=foo&utm_campaign=bar"); + const params = addUtmParams(url, "foo").searchParams; + assert.equal(params.get("utm_source"), "foo", "utm_source"); + assert.equal(params.get("utm_campaign"), "bar", "utm_campaign"); + assert.equal(params.get("utm_medium"), "referral", "utm_medium"); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js new file mode 100644 index 0000000000..5a7fad7cc0 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js @@ -0,0 +1,120 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start"; + +describe("detectUserSessionStart", () => { + let store; + class PerfService { + getMostRecentAbsMarkStartByName() { + return 1234; + } + mark() {} + } + + beforeEach(() => { + store = { dispatch: () => {} }; + }); + describe("#sendEventOrAddListener", () => { + it("should call ._sendEvent immediately if the document is visible", () => { + const mockDocument = { visibilityState: "visible" }; + const instance = new DetectUserSessionStart(store, { + document: mockDocument, + }); + sinon.stub(instance, "_sendEvent"); + + instance.sendEventOrAddListener(); + + assert.calledOnce(instance._sendEvent); + }); + it("should add an event listener on visibility changes the document is not visible", () => { + const mockDocument = { + visibilityState: "hidden", + addEventListener: sinon.spy(), + }; + const instance = new DetectUserSessionStart(store, { + document: mockDocument, + }); + sinon.stub(instance, "_sendEvent"); + + instance.sendEventOrAddListener(); + + assert.notCalled(instance._sendEvent); + assert.calledWith( + mockDocument.addEventListener, + "visibilitychange", + instance._onVisibilityChange + ); + }); + }); + describe("#_sendEvent", () => { + it("should dispatch an action with the SAVE_SESSION_PERF_DATA", () => { + const dispatch = sinon.spy(store, "dispatch"); + const instance = new DetectUserSessionStart(store); + + instance._sendEvent(); + + assert.calledWith( + dispatch, + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { visibility_event_rcvd_ts: sinon.match.number }, + }) + ); + }); + + it("shouldn't send a message if getMostRecentAbsMarkStartByName throws", () => { + let perfService = new PerfService(); + sinon.stub(perfService, "getMostRecentAbsMarkStartByName").throws(); + const dispatch = sinon.spy(store, "dispatch"); + const instance = new DetectUserSessionStart(store, { perfService }); + + instance._sendEvent(); + + assert.notCalled(dispatch); + }); + + it('should call perfService.mark("visibility_event_rcvd_ts")', () => { + let perfService = new PerfService(); + sinon.stub(perfService, "mark"); + const instance = new DetectUserSessionStart(store, { perfService }); + + instance._sendEvent(); + + assert.calledWith(perfService.mark, "visibility_event_rcvd_ts"); + }); + }); + + describe("_onVisibilityChange", () => { + it("should not send an event if visiblity is not visible", () => { + const instance = new DetectUserSessionStart(store, { + document: { visibilityState: "hidden" }, + }); + sinon.stub(instance, "_sendEvent"); + + instance._onVisibilityChange(); + + assert.notCalled(instance._sendEvent); + }); + it("should send an event and remove the event listener if visibility is visible", () => { + const mockDocument = { + visibilityState: "visible", + removeEventListener: sinon.spy(), + }; + const instance = new DetectUserSessionStart(store, { + document: mockDocument, + }); + sinon.stub(instance, "_sendEvent"); + + instance._onVisibilityChange(); + + assert.calledOnce(instance._sendEvent); + assert.calledWith( + mockDocument.removeEventListener, + "visibilitychange", + instance._onVisibilityChange + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/lib/init-store.test.js b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js new file mode 100644 index 0000000000..5ce92d2192 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/lib/init-store.test.js @@ -0,0 +1,207 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { addNumberReducer, GlobalOverrider } from "test/unit/utils"; +import { + EARLY_QUEUED_ACTIONS, + INCOMING_MESSAGE_NAME, + initStore, + MERGE_STORE_ACTION, + OUTGOING_MESSAGE_NAME, + queueEarlyMessageMiddleware, + rehydrationMiddleware, +} from "content-src/lib/init-store"; + +describe("initStore", () => { + let globals; + let store; + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set("RPMSendAsyncMessage", globals.sandbox.spy()); + globals.set("RPMAddMessageListener", globals.sandbox.spy()); + store = initStore({ number: addNumberReducer }); + }); + afterEach(() => globals.restore()); + it("should create a store with the provided reducers", () => { + assert.ok(store); + assert.property(store.getState(), "number"); + }); + it("should add a listener that dispatches actions", () => { + assert.calledWith(global.RPMAddMessageListener, INCOMING_MESSAGE_NAME); + const [, listener] = global.RPMAddMessageListener.firstCall.args; + globals.sandbox.spy(store, "dispatch"); + const message = { name: INCOMING_MESSAGE_NAME, data: { type: "FOO" } }; + + listener(message); + + assert.calledWith(store.dispatch, message.data); + }); + it("should not throw if RPMAddMessageListener is not defined", () => { + // Note: this is being set/restored by GlobalOverrider + delete global.RPMAddMessageListener; + + assert.doesNotThrow(() => initStore({ number: addNumberReducer })); + }); + it("should log errors from failed messages", () => { + const [, callback] = global.RPMAddMessageListener.firstCall.args; + globals.sandbox.stub(global.console, "error"); + globals.sandbox.stub(store, "dispatch").throws(Error("failed")); + + const message = { + name: INCOMING_MESSAGE_NAME, + data: { type: MERGE_STORE_ACTION }, + }; + callback(message); + + assert.calledOnce(global.console.error); + }); + it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => { + store.dispatch({ type: MERGE_STORE_ACTION, data: { number: 42 } }); + assert.deepEqual(store.getState(), { number: 42 }); + }); + it("should call .send and update the local store if an AlsoToMain action is dispatched", () => { + const subscriber = sinon.spy(); + const action = ac.AlsoToMain({ type: "FOO" }); + + store.subscribe(subscriber); + store.dispatch(action); + + assert.calledWith( + global.RPMSendAsyncMessage, + OUTGOING_MESSAGE_NAME, + action + ); + assert.calledOnce(subscriber); + }); + it("should call .send but not update the local store if an OnlyToMain action is dispatched", () => { + const subscriber = sinon.spy(); + const action = ac.OnlyToMain({ type: "FOO" }); + + store.subscribe(subscriber); + store.dispatch(action); + + assert.calledWith( + global.RPMSendAsyncMessage, + OUTGOING_MESSAGE_NAME, + action + ); + assert.notCalled(subscriber); + }); + it("should not send out other types of actions", () => { + store.dispatch({ type: "FOO" }); + assert.notCalled(global.RPMSendAsyncMessage); + }); + describe("rehydrationMiddleware", () => { + it("should allow NEW_TAB_STATE_REQUEST to go through", () => { + const action = ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }); + const next = sinon.spy(); + rehydrationMiddleware(store)(next)(action); + assert.calledWith(next, action); + }); + it("should dispatch an additional NEW_TAB_STATE_REQUEST if INIT was received after a request", () => { + const requestAction = ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }); + const next = sinon.spy(); + const dispatch = rehydrationMiddleware(store)(next); + + dispatch(requestAction); + next.resetHistory(); + dispatch({ type: at.INIT }); + + assert.calledWith(next, requestAction); + }); + it("should allow MERGE_STORE_ACTION to go through", () => { + const action = { type: MERGE_STORE_ACTION }; + const next = sinon.spy(); + rehydrationMiddleware(store)(next)(action); + assert.calledWith(next, action); + }); + it("should not allow actions from main to go through before MERGE_STORE_ACTION was received", () => { + const next = sinon.spy(); + const dispatch = rehydrationMiddleware(store)(next); + + dispatch(ac.BroadcastToContent({ type: "FOO" })); + dispatch(ac.AlsoToOneContent({ type: "FOO" }, 123)); + + assert.notCalled(next); + }); + it("should allow all local actions to go through", () => { + const action = { type: "FOO" }; + const next = sinon.spy(); + rehydrationMiddleware(store)(next)(action); + assert.calledWith(next, action); + }); + it("should allow actions from main to go through after MERGE_STORE_ACTION has been received", () => { + const next = sinon.spy(); + const dispatch = rehydrationMiddleware(store)(next); + + dispatch({ type: MERGE_STORE_ACTION }); + next.resetHistory(); + + const action = ac.AlsoToOneContent({ type: "FOO" }, 123); + dispatch(action); + assert.calledWith(next, action); + }); + it("should not let startup actions go through for the preloaded about:home document", () => { + globals.set("__FROM_STARTUP_CACHE__", true); + const next = sinon.spy(); + const dispatch = rehydrationMiddleware(store)(next); + const action = ac.BroadcastToContent( + { type: "FOO", meta: { isStartup: true } }, + 123 + ); + dispatch(action); + assert.notCalled(next); + }); + }); + describe("queueEarlyMessageMiddleware", () => { + it("should allow all local actions to go through", () => { + const action = { type: "FOO" }; + const next = sinon.spy(); + + queueEarlyMessageMiddleware(store)(next)(action); + + assert.calledWith(next, action); + }); + it("should allow action to main that does not belong to EARLY_QUEUED_ACTIONS to go through", () => { + const action = ac.AlsoToMain({ type: "FOO" }); + const next = sinon.spy(); + + queueEarlyMessageMiddleware(store)(next)(action); + + assert.calledWith(next, action); + }); + it(`should line up EARLY_QUEUED_ACTIONS only let them go through after it receives the action from main`, () => { + EARLY_QUEUED_ACTIONS.forEach(actionType => { + const testStore = initStore({ number: addNumberReducer }); + const next = sinon.spy(); + const dispatch = queueEarlyMessageMiddleware(testStore)(next); + const action = ac.AlsoToMain({ type: actionType }); + const fromMainAction = ac.AlsoToOneContent({ type: "FOO" }, 123); + + // Early actions should be added to the queue + dispatch(action); + dispatch(action); + + assert.notCalled(next); + assert.equal(testStore.getState.earlyActionQueue.length, 2); + next.resetHistory(); + + // Receiving action from main would empty the queue + dispatch(fromMainAction); + + assert.calledThrice(next); + assert.equal(next.firstCall.args[0], fromMainAction); + assert.equal(next.secondCall.args[0], action); + assert.equal(next.thirdCall.args[0], action); + assert.equal(testStore.getState.earlyActionQueue.length, 0); + next.resetHistory(); + + // New action should go through immediately + dispatch(action); + assert.calledOnce(next); + assert.calledWith(next, action); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js b/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js new file mode 100644 index 0000000000..9cabfb5029 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/lib/perf-service.test.js @@ -0,0 +1,89 @@ +/* globals assert, beforeEach, describe, it */ +import { _PerfService } from "content-src/lib/perf-service"; +import { FakePerformance } from "test/unit/utils.js"; + +let perfService; + +describe("_PerfService", () => { + let sandbox; + let fakePerfObj; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + fakePerfObj = new FakePerformance(); + perfService = new _PerfService({ performanceObj: fakePerfObj }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("#absNow", () => { + it("should return a number > the time origin", () => { + const absNow = perfService.absNow(); + + assert.isAbove(absNow, perfService.timeOrigin); + }); + }); + describe("#getEntriesByName", () => { + it("should call getEntriesByName on the appropriate Window.performance", () => { + sandbox.spy(fakePerfObj, "getEntriesByName"); + + perfService.getEntriesByName("monkey", "mark"); + + assert.calledOnce(fakePerfObj.getEntriesByName); + assert.calledWithExactly(fakePerfObj.getEntriesByName, "monkey", "mark"); + }); + + it("should return entries with the given name", () => { + sandbox.spy(fakePerfObj, "getEntriesByName"); + perfService.mark("monkey"); + perfService.mark("dog"); + + let marks = perfService.getEntriesByName("monkey", "mark"); + + assert.isArray(marks); + assert.lengthOf(marks, 1); + assert.propertyVal(marks[0], "name", "monkey"); + }); + }); + + describe("#getMostRecentAbsMarkStartByName", () => { + it("should throw an error if there is no mark with the given name", () => { + function bogusGet() { + perfService.getMostRecentAbsMarkStartByName("rheeeet"); + } + + assert.throws(bogusGet, Error, /No marks with the name/); + }); + + it("should return the Number from the most recent mark with the given name + the time origin", () => { + perfService.mark("dog"); + perfService.mark("dog"); + + let absMarkStart = perfService.getMostRecentAbsMarkStartByName("dog"); + + // 2 because we want the result of the 2nd call to mark, and an instance + // of FakePerformance just returns the number of time mark has been + // called. + assert.equal(absMarkStart - perfService.timeOrigin, 2); + }); + }); + + describe("#mark", () => { + it("should call the wrapped version of mark", () => { + sandbox.spy(fakePerfObj, "mark"); + + perfService.mark("monkey"); + + assert.calledOnce(fakePerfObj.mark); + assert.calledWithExactly(fakePerfObj.mark, "monkey"); + }); + }); + + describe("#timeOrigin", () => { + it("should get the origin of the wrapped performance object", () => { + assert.equal(perfService.timeOrigin, fakePerfObj.timeOrigin); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js b/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js new file mode 100644 index 0000000000..ef7e7cf5f6 --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js @@ -0,0 +1,147 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; + +const DEFAULT_BLOB_URL = "blob://test"; + +describe("ScreenshotUtils", () => { + let globals; + let url; + beforeEach(() => { + globals = new GlobalOverrider(); + url = { + createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL), + revokeObjectURL: globals.sandbox.spy(), + }; + globals.set("URL", url); + }); + afterEach(() => globals.restore()); + describe("#createLocalImageObject", () => { + it("should return null if no remoteImage is supplied", () => { + let localImageObject = ScreenshotUtils.createLocalImageObject(null); + + assert.notCalled(url.createObjectURL); + assert.equal(localImageObject, null); + }); + it("should create a local image object with the correct properties if remoteImage is a blob", () => { + let localImageObject = ScreenshotUtils.createLocalImageObject({ + path: "/path1", + data: new Blob([0]), + }); + + assert.calledOnce(url.createObjectURL); + assert.deepEqual(localImageObject, { + path: "/path1", + url: DEFAULT_BLOB_URL, + }); + }); + it("should create a local image object with the correct properties if remoteImage is a normal image", () => { + const imageUrl = "https://test-url"; + let localImageObject = ScreenshotUtils.createLocalImageObject(imageUrl); + + assert.notCalled(url.createObjectURL); + assert.deepEqual(localImageObject, { url: imageUrl }); + }); + }); + describe("#maybeRevokeBlobObjectURL", () => { + // Note that we should also ensure that all the tests for #isBlob are green. + it("should call revokeObjectURL if image is a blob", () => { + ScreenshotUtils.maybeRevokeBlobObjectURL({ + path: "/path1", + url: "blob://test", + }); + + assert.calledOnce(url.revokeObjectURL); + }); + it("should not call revokeObjectURL if image is not a blob", () => { + ScreenshotUtils.maybeRevokeBlobObjectURL({ url: "https://test-url" }); + + assert.notCalled(url.revokeObjectURL); + }); + }); + describe("#isRemoteImageLocal", () => { + it("should return true if both propsImage and stateImage are not present", () => { + assert.isTrue(ScreenshotUtils.isRemoteImageLocal(null, null)); + }); + it("should return false if propsImage is present and stateImage is not present", () => { + assert.isFalse(ScreenshotUtils.isRemoteImageLocal(null, {})); + }); + it("should return false if propsImage is not present and stateImage is present", () => { + assert.isFalse(ScreenshotUtils.isRemoteImageLocal({}, null)); + }); + it("should return true if both propsImage and stateImage are equal blobs", () => { + const blobPath = "/test-blob-path/test.png"; + assert.isTrue( + ScreenshotUtils.isRemoteImageLocal( + { path: blobPath, url: "blob://test" }, // state + { path: blobPath, data: new Blob([0]) } // props + ) + ); + }); + it("should return false if both propsImage and stateImage are different blobs", () => { + assert.isFalse( + ScreenshotUtils.isRemoteImageLocal( + { path: "/path1", url: "blob://test" }, // state + { path: "/path2", data: new Blob([0]) } // props + ) + ); + }); + it("should return true if both propsImage and stateImage are equal normal images", () => { + assert.isTrue( + ScreenshotUtils.isRemoteImageLocal( + { url: "test url" }, // state + "test url" // props + ) + ); + }); + it("should return false if both propsImage and stateImage are different normal images", () => { + assert.isFalse( + ScreenshotUtils.isRemoteImageLocal( + { url: "test url 1" }, // state + "test url 2" // props + ) + ); + }); + it("should return false if both propsImage and stateImage are different type of images", () => { + assert.isFalse( + ScreenshotUtils.isRemoteImageLocal( + { path: "/path1", url: "blob://test" }, // state + "test url 2" // props + ) + ); + assert.isFalse( + ScreenshotUtils.isRemoteImageLocal( + { url: "https://test-url" }, // state + { path: "/path1", data: new Blob([0]) } // props + ) + ); + }); + }); + describe("#isBlob", () => { + let state = { + blobImage: { path: "/test", url: "blob://test" }, + normalImage: { url: "https://test-url" }, + }; + let props = { + blobImage: { path: "/test", data: new Blob([0]) }, + normalImage: "https://test-url", + }; + it("should return false if image is null", () => { + assert.isFalse(ScreenshotUtils.isBlob(true, null)); + assert.isFalse(ScreenshotUtils.isBlob(false, null)); + }); + it("should return true if image is a blob and type matches", () => { + assert.isTrue(ScreenshotUtils.isBlob(true, state.blobImage)); + assert.isTrue(ScreenshotUtils.isBlob(false, props.blobImage)); + }); + it("should return false if image is not a blob and type matches", () => { + assert.isFalse(ScreenshotUtils.isBlob(true, state.normalImage)); + assert.isFalse(ScreenshotUtils.isBlob(false, props.normalImage)); + }); + it("should return false if type does not match", () => { + assert.isFalse(ScreenshotUtils.isBlob(false, state.blobImage)); + assert.isFalse(ScreenshotUtils.isBlob(false, state.normalImage)); + assert.isFalse(ScreenshotUtils.isBlob(true, props.blobImage)); + assert.isFalse(ScreenshotUtils.isBlob(true, props.normalImage)); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js new file mode 100644 index 0000000000..233f31b6ca --- /dev/null +++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js @@ -0,0 +1,576 @@ +import { combineReducers, createStore } from "redux"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { reducers } from "common/Reducers.sys.mjs"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +const FAKE_LAYOUT = [ + { + width: 3, + components: [ + { type: "foo", feed: { url: "foo.com" }, properties: { items: 2 } }, + ], + }, +]; +const FAKE_FEEDS = { + "foo.com": { data: { recommendations: [{ id: "foo" }, { id: "bar" }] } }, +}; + +describe("selectLayoutRender", () => { + let store; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + store = createStore(combineReducers(reducers)); + }); + + afterEach(() => { + globals.restore(); + }); + + it("should return an empty array given initial state", () => { + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + prefs: {}, + rollCache: [], + }); + assert.deepEqual(layoutRender, []); + }); + + it("should add .data property from feeds to each compontent in .layout", () => { + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: FAKE_LAYOUT }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.propertyVal(layoutRender[0], "width", 3); + assert.deepEqual(layoutRender[0].components[0], { + type: "foo", + feed: { url: "foo.com" }, + properties: { items: 2 }, + data: { + recommendations: [ + { id: "foo", pos: 0 }, + { id: "bar", pos: 1 }, + ], + }, + }); + }); + + it("should return layout with placeholder data if feed doesn't have data", () => { + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: FAKE_LAYOUT }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.propertyVal(layoutRender[0], "width", 3); + assert.deepEqual(layoutRender[0].components[0].data.recommendations, [ + { placeholder: true }, + { placeholder: true }, + ]); + }); + + it("should return layout with empty spocs data if feed isn't defined but spocs is", () => { + const fakeLayout = [ + { + width: 3, + components: [{ type: "foo", spocs: { positions: [{ index: 2 }] } }], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.propertyVal(layoutRender[0], "width", 3); + assert.deepEqual(layoutRender[0].components[0].data.spocs, []); + }); + + it("should return layout with spocs data if feed isn't defined but spocs is", () => { + const fakeLayout = [ + { + width: 3, + components: [{ type: "foo", spocs: { positions: [{ index: 0 }] } }], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: { + lastUpdated: 0, + spocs: { + spocs: { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }, + }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.propertyVal(layoutRender[0], "width", 3); + assert.deepEqual(layoutRender[0].components[0].data.spocs, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + { id: 3, pos: 2 }, + ]); + }); + + it("should return layout with no spocs data if feed and spocs are unavailable", () => { + const fakeLayout = [ + { + width: 3, + components: [{ type: "foo", spocs: { positions: [{ index: 0 }] } }], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: { + lastUpdated: 0, + spocs: { + spocs: { + items: [], + }, + }, + }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.propertyVal(layoutRender[0], "width", 3); + assert.equal(layoutRender[0].components[0].data.spocs.length, 0); + }); + + it("should return feed data offset by layout set prop", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo", properties: { offset: 1 }, feed: { url: "foo.com" } }, + ], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.deepEqual(layoutRender[0].components[0].data, { + recommendations: [{ id: "bar" }], + }); + }); + + it("should return spoc result when there are more positions than spocs", () => { + const fakeSpocConfig = { + positions: [{ index: 0 }, { index: 1 }, { index: 2 }], + }; + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo", feed: { url: "foo.com" }, spocs: fakeSpocConfig }, + ], + }, + ]; + const fakeSpocsData = { + lastUpdated: 0, + spocs: { spocs: { items: ["fooSpoc", "barSpoc"] } }, + }; + + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: FAKE_FEEDS["foo.com"], url: "foo.com" }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: fakeSpocsData, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.lengthOf(layoutRender, 1); + assert.deepEqual( + layoutRender[0].components[0].data.recommendations[0], + "fooSpoc" + ); + assert.deepEqual( + layoutRender[0].components[0].data.recommendations[1], + "barSpoc" + ); + assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], { + id: "foo", + }); + assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], { + id: "bar", + }); + }); + + it("should return a layout with feeds of items length with positions", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo", properties: { items: 3 }, feed: { url: "foo.com" } }, + ], + }, + ]; + const fakeRecommendations = [ + { name: "item1" }, + { name: "item2" }, + { name: "item3" }, + { name: "item4" }, + ]; + const fakeFeeds = { + "foo.com": { data: { recommendations: fakeRecommendations } }, + }; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: fakeFeeds["foo.com"], url: "foo.com" }, + }); + store.dispatch({ type: at.DISCOVERY_STREAM_FEEDS_UPDATE }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + const { recommendations } = layoutRender[0].components[0].data; + assert.equal(recommendations.length, 4); + assert.equal(recommendations[0].pos, 0); + assert.equal(recommendations[1].pos, 1); + assert.equal(recommendations[2].pos, 2); + assert.equal(recommendations[3].pos, undefined); + }); + it("should stop rendering feeds if we hit one that's not ready", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo1" }, + { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } }, + { type: "foo3", properties: { items: 3 }, feed: { url: "foo3.com" } }, + { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } }, + { type: "foo5" }, + ], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo2.com" }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.equal(layoutRender[0].components[0].type, "foo1"); + assert.equal(layoutRender[0].components[1].type, "foo2"); + assert.isTrue( + layoutRender[0].components[2].data.recommendations[0].placeholder + ); + assert.lengthOf(layoutRender[0].components, 3); + assert.isUndefined(layoutRender[0].components[3]); + }); + it("should render everything if everything is ready", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo1" }, + { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } }, + { type: "foo3", properties: { items: 3 }, feed: { url: "foo3.com" } }, + { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } }, + { type: "foo5" }, + ], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo2.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo3.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo4.com" }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.equal(layoutRender[0].components[0].type, "foo1"); + assert.equal(layoutRender[0].components[1].type, "foo2"); + assert.equal(layoutRender[0].components[2].type, "foo3"); + assert.equal(layoutRender[0].components[3].type, "foo4"); + assert.equal(layoutRender[0].components[4].type, "foo5"); + }); + it("should stop rendering feeds if we hit a not ready spoc", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo1" }, + { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } }, + { + type: "foo3", + properties: { items: 3 }, + feed: { url: "foo3.com" }, + spocs: { positions: [{ index: 0 }] }, + }, + { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } }, + { type: "foo5" }, + ], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo2.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo3.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo4.com" }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.equal(layoutRender[0].components[0].type, "foo1"); + assert.equal(layoutRender[0].components[1].type, "foo2"); + assert.deepEqual(layoutRender[0].components[2].data.recommendations, [ + { placeholder: true }, + { placeholder: true }, + { placeholder: true }, + ]); + }); + it("should not render a spoc if there are no available spocs", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { type: "foo1" }, + { type: "foo2", properties: { items: 3 }, feed: { url: "foo2.com" } }, + { + type: "foo3", + properties: { items: 3 }, + feed: { url: "foo3.com" }, + spocs: { positions: [{ index: 0 }] }, + }, + { type: "foo4", properties: { items: 3 }, feed: { url: "foo4.com" } }, + { type: "foo5" }, + ], + }, + ]; + const fakeSpocsData = { lastUpdated: 0, spocs: { spocs: [] } }; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo2.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { data: { recommendations: [{ name: "rec" }] } }, + url: "foo3.com", + }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { recommendations: [] } }, url: "foo4.com" }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: fakeSpocsData, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.deepEqual(layoutRender[0].components[2].data.recommendations[0], { + name: "rec", + pos: 0, + }); + }); + it("should not render a row if no components exist after filter in that row", () => { + const fakeLayout = [ + { + width: 3, + components: [{ type: "TopSites" }], + }, + { + width: 3, + components: [{ type: "Message" }], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + prefs: { "feeds.topsites": true }, + }); + + assert.equal(layoutRender[0].components[0].type, "TopSites"); + assert.equal(layoutRender[1], undefined); + }); + it("should not render a component if filtered", () => { + const fakeLayout = [ + { + width: 3, + components: [{ type: "Message" }, { type: "TopSites" }], + }, + ]; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + + const { layoutRender } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + prefs: { "feeds.topsites": true }, + }); + + assert.equal(layoutRender[0].components[0].type, "TopSites"); + assert.equal(layoutRender[0].components[1], undefined); + }); + it("should skip rendering a spoc in position if that spoc is blocked for that session", () => { + const fakeLayout = [ + { + width: 3, + components: [ + { + type: "foo1", + properties: { items: 3 }, + feed: { url: "foo1.com" }, + spocs: { positions: [{ index: 0 }] }, + }, + ], + }, + ]; + const fakeSpocsData = { + lastUpdated: 0, + spocs: { + spocs: { items: [{ name: "spoc", url: "https://foo.com" }] }, + }, + }; + store.dispatch({ + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: fakeLayout }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { data: { recommendations: [{ name: "rec" }] } }, + url: "foo1.com", + }, + }); + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: fakeSpocsData, + }); + + const { layoutRender: layout1 } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + store.dispatch({ + type: at.DISCOVERY_STREAM_SPOC_BLOCKED, + data: { url: "https://foo.com" }, + }); + + const { layoutRender: layout2 } = selectLayoutRender({ + state: store.getState().DiscoveryStream, + }); + + assert.deepEqual(layout1[0].components[0].data.recommendations[0], { + name: "spoc", + url: "https://foo.com", + pos: 0, + }); + assert.deepEqual(layout2[0].components[0].data.recommendations[0], { + name: "rec", + pos: 0, + }); + }); +}); |