diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents')
20 files changed, 2695 insertions, 0 deletions
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, + }) + ); + }); + }); +}); |