diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/newtab/test/unit | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/test/unit')
129 files changed, 49149 insertions, 0 deletions
diff --git a/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx new file mode 100644 index 0000000000..a9f401f6b7 --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/AWScreenUtils.test.jsx @@ -0,0 +1,140 @@ +import { AWScreenUtils } from "lib/AWScreenUtils.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { ASRouter } from "lib/ASRouter.jsm"; + +describe("AWScreenUtils", () => { + let sandbox; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set({ + ASRouter, + ASRouterTargeting: { + Environment: {}, + }, + }); + + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("removeScreens", () => { + it("should run callback function once for each array element", async () => { + const callback = sandbox.stub().resolves(false); + const arr = ["foo", "bar"]; + await AWScreenUtils.removeScreens(arr, callback); + assert.calledTwice(callback); + }); + it("should remove screen when passed function evaluates true", async () => { + const callback = sandbox.stub().resolves(true); + const arr = ["foo", "bar"]; + await AWScreenUtils.removeScreens(arr, callback); + assert.deepEqual(arr, []); + }); + }); + describe("evaluateScreenTargeting", () => { + it("should return the eval result if the eval succeeds", async () => { + const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({ + evaluationStatus: { + success: true, + result: false, + }, + }); + const result = await AWScreenUtils.evaluateScreenTargeting( + "test expression" + ); + assert.calledOnce(evalStub); + assert.equal(result, false); + }); + it("should return true if the targeting eval fails", async () => { + const evalStub = sandbox.stub(ASRouter, "evaluateExpression").resolves({ + evaluationStatus: { + success: false, + result: false, + }, + }); + const result = await AWScreenUtils.evaluateScreenTargeting( + "test expression" + ); + assert.calledOnce(evalStub); + assert.equal(result, true); + }); + }); + describe("evaluateTargetingAndRemoveScreens", () => { + it("should manipulate an array of screens", async () => { + const screens = [ + { + id: "first", + targeting: true, + }, + { + id: "second", + targeting: false, + }, + ]; + + const expectedScreens = [ + { + id: "first", + targeting: true, + }, + ]; + sandbox.stub(ASRouter, "evaluateExpression").callsFake(targeting => { + return { + evaluationStatus: { + success: true, + result: targeting.expression, + }, + }; + }); + const evaluatedStrings = + await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens); + assert.deepEqual(evaluatedStrings, expectedScreens); + }); + it("should not remove screens with no targeting", async () => { + const screens = [ + { + id: "first", + }, + { + id: "second", + targeting: false, + }, + ]; + + const expectedScreens = [ + { + id: "first", + }, + ]; + sandbox + .stub(AWScreenUtils, "evaluateScreenTargeting") + .callsFake(targeting => { + if (targeting === undefined) { + return true; + } + return targeting; + }); + const evaluatedStrings = + await AWScreenUtils.evaluateTargetingAndRemoveScreens(screens); + assert.deepEqual(evaluatedStrings, expectedScreens); + }); + }); + + describe("addScreenImpression", () => { + it("Should call addScreenImpression with provided screen ID", () => { + const addScreenImpressionStub = sandbox.stub( + ASRouter, + "addScreenImpression" + ); + const testScreen = { id: "test" }; + AWScreenUtils.addScreenImpression(testScreen); + + assert.calledOnce(addScreenImpressionStub); + assert.equal(addScreenImpressionStub.firstCall.args[0].id, testScreen.id); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx new file mode 100644 index 0000000000..57773b0e82 --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/CTAParagraph.test.jsx @@ -0,0 +1,49 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { CTAParagraph } from "content-src/aboutwelcome/components/CTAParagraph"; + +describe("CTAParagraph component", () => { + let sandbox; + let wrapper; + let handleAction; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleAction = sandbox.stub(); + wrapper = shallow( + <CTAParagraph + content={{ + text: { + raw: "Link Text", + string_name: "Test Name", + }, + }} + handleAction={handleAction} + /> + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render CTAParagraph component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render CTAParagraph component if only CTA text is passed", () => { + wrapper.setProps({ content: { text: "CTA Text" } }); + assert.ok(wrapper.exists()); + }); + + it("should call handleAction method when button is link is clicked", () => { + const btnLink = wrapper.find(".cta-paragraph span"); + btnLink.simulate("click"); + assert.calledOnce(handleAction); + }); + + it("should not render CTAParagraph component if CTA text is not passed", () => { + wrapper.setProps({ content: { text: null } }); + assert.ok(wrapper.isEmptyRender()); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx new file mode 100644 index 0000000000..8c9bdc8e50 --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/HeroImage.test.jsx @@ -0,0 +1,40 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { HeroImage } from "content-src/aboutwelcome/components/HeroImage"; + +describe("HeroImage component", () => { + const imageUrl = "https://example.com"; + const imageHeight = "100px"; + const imageAlt = "Alt text"; + + let wrapper; + beforeEach(() => { + wrapper = shallow( + <HeroImage url={imageUrl} alt={imageAlt} height={imageHeight} /> + ); + }); + + it("should render HeroImage component", () => { + assert.ok(wrapper.exists()); + }); + + it("should render an image element with src prop", () => { + let imgEl = wrapper.find("img"); + assert.strictEqual(imgEl.prop("src"), imageUrl); + }); + + it("should render image element with alt text prop", () => { + let imgEl = wrapper.find("img"); + assert.equal(imgEl.prop("alt"), imageAlt); + }); + + it("should render an image with a set height prop", () => { + let imgEl = wrapper.find("img"); + assert.propertyVal(imgEl.prop("style"), "height", imageHeight); + }); + + it("should not render HeroImage component", () => { + wrapper.setProps({ url: null }); + assert.ok(wrapper.isEmptyRender()); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx new file mode 100644 index 0000000000..c8829d76a7 --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/MRColorways.test.jsx @@ -0,0 +1,328 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { + Colorways, + computeColorWay, + ColorwayDescription, + computeVariationIndex, +} from "content-src/aboutwelcome/components/MRColorways"; +import { WelcomeScreen } from "content-src/aboutwelcome/components/MultiStageAboutWelcome"; + +describe("Multistage AboutWelcome module", () => { + let sandbox; + let COLORWAY_SCREEN_PROPS; + beforeEach(() => { + sandbox = sinon.createSandbox(); + COLORWAY_SCREEN_PROPS = { + id: "test-colorway-screen", + totalNumberofScreens: 1, + content: { + subtitle: "test subtitle", + tiles: { + type: "colorway", + action: { + theme: "<event>", + }, + defaultVariationIndex: 0, + systemVariations: ["automatic", "light"], + variations: ["soft", "bold"], + colorways: [ + { + id: "default", + label: "Default", + }, + { + id: "abstract", + label: "Abstract", + }, + ], + }, + primary_button: { + action: {}, + label: "test button", + }, + }, + messageId: "test-mr-colorway-screen", + activeTheme: "automatic", + }; + }); + afterEach(() => { + sandbox.restore(); + }); + + describe("MRColorway component", () => { + it("should render WelcomeScreen", () => { + const wrapper = shallow(<WelcomeScreen {...COLORWAY_SCREEN_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should use default when activeTheme is not set", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + wrapper.setProps({ activeTheme: null }); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + assert.strictEqual(colorwaysOptionIcons.length, 2); + + // Default automatic theme is selected by default + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("default"), + true + ); + }); + + it("should use default when activeTheme is alpenglow", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + wrapper.setProps({ activeTheme: "alpenglow" }); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + assert.strictEqual(colorwaysOptionIcons.length, 2); + + // Default automatic theme is selected when unsupported in colorway alpenglow theme is active + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("default"), + true + ); + }); + + it("should render colorways options", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + + const colorwaysOptions = wrapper.find( + ".tiles-theme-section .theme input[name='theme']" + ); + + const colorwaysOptionIcons = wrapper.find( + ".tiles-theme-section .theme .icon" + ); + + const colorwaysLabels = wrapper.find( + ".tiles-theme-section .theme span.sr-only" + ); + + assert.strictEqual(colorwaysOptions.length, 2); + assert.strictEqual(colorwaysOptionIcons.length, 2); + assert.strictEqual(colorwaysLabels.length, 2); + + // First colorway option + // Default theme radio option is selected by default + assert.strictEqual( + colorwaysOptionIcons.first().prop("className").includes("selected"), + true + ); + + //Colorway should be using id property + assert.strictEqual( + colorwaysOptions.first().prop("data-colorway"), + "default" + ); + + // Second colorway option + assert.strictEqual( + colorwaysOptionIcons.last().prop("className").includes("selected"), + false + ); + + //Colorway should be using id property + assert.strictEqual( + colorwaysOptions.last().prop("data-colorway"), + "abstract" + ); + + //Colorway should be labelled for screen readers (parent label is for tooltip only, and does not describe the Colorway) + assert.strictEqual( + colorwaysOptions.last().prop("aria-labelledby"), + "abstract-label" + ); + }); + + it("should handle colorway clicks", () => { + sandbox.stub(React, "useEffect").callsFake((fn, vals) => { + if (vals === undefined) { + fn(); + } else if (vals[0] === "in") { + fn(); + } + }); + + const handleAction = sandbox.stub(); + const wrapper = shallow( + <Colorways handleAction={handleAction} {...COLORWAY_SCREEN_PROPS} /> + ); + const colorwaysOptions = wrapper.find( + ".tiles-theme-section .theme input[name='theme']" + ); + + let props = wrapper.find(ColorwayDescription).props(); + assert.propertyVal(props.colorway, "label", "Default"); + + const option = colorwaysOptions.last(); + assert.propertyVal(option.props(), "value", "abstract-soft"); + colorwaysOptions.last().simulate("click"); + assert.calledOnce(handleAction); + }); + + it("should render colorway description", () => { + const wrapper = shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + + let descriptionsWrapper = wrapper.find(ColorwayDescription); + assert.ok(descriptionsWrapper.exists()); + + let props = descriptionsWrapper.props(); + + // Colorway description should display Default theme desc by default + assert.strictEqual(props.colorway.label, "Default"); + }); + + it("ColorwayDescription should display active colorway desc", () => { + let TEST_COLORWAY_PROPS = { + colorway: { + label: "Activist", + description: "Test Activist", + }, + }; + const descWrapper = shallow( + <ColorwayDescription {...TEST_COLORWAY_PROPS} /> + ); + assert.ok(descWrapper.exists()); + const descText = descWrapper.find(".colorway-text"); + assert.equal( + descText.props()["data-l10n-args"].includes("Activist"), + true + ); + }); + + it("should computeColorWayId for default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const colorwayId = computeColorWay( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations + ); + assert.strictEqual(colorwayId, "default"); + }); + + it("should computeColorWayId for non-default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + activeTheme: "abstract-soft", + }; + + const colorwayId = computeColorWay( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations + ); + assert.strictEqual(colorwayId, "abstract"); + }); + + it("should computeVariationIndex for default active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + TEST_COLORWAY_PROPS.activeTheme, + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual( + variationIndex, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + }); + + it("should computeVariationIndex for active theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + "light", + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual(variationIndex, 1); + }); + + it("should computeVariationIndex for colorway theme", () => { + let TEST_COLORWAY_PROPS = { + ...COLORWAY_SCREEN_PROPS, + }; + + const variationIndex = computeVariationIndex( + "abstract-bold", + TEST_COLORWAY_PROPS.content.tiles.systemVariations, + TEST_COLORWAY_PROPS.content.tiles.variations, + TEST_COLORWAY_PROPS.content.tiles.defaultVariationIndex + ); + assert.strictEqual(variationIndex, 1); + }); + + describe("random colorways", () => { + let test; + beforeEach(() => { + COLORWAY_SCREEN_PROPS.handleAction = sandbox.stub(); + sandbox.stub(window, "matchMedia"); + // eslint-disable-next-line max-nested-callbacks + sandbox.stub(React, "useEffect").callsFake((fn, vals) => { + if (vals?.length === 0) { + fn(); + } + }); + test = () => { + shallow(<Colorways {...COLORWAY_SCREEN_PROPS} />); + return COLORWAY_SCREEN_PROPS.handleAction.firstCall.firstArg + .currentTarget; + }; + }); + + it("should select a random colorway", () => { + const { value } = test(); + + assert.strictEqual(value, "abstract-soft"); + assert.calledThrice(React.useEffect); + assert.notCalled(window.matchMedia); + }); + + it("should select a random soft colorway when not dark", () => { + window.matchMedia.returns({ matches: false }); + COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1; + + const { value } = test(); + + assert.strictEqual(value, "abstract-soft"); + assert.calledThrice(React.useEffect); + assert.calledOnce(window.matchMedia); + }); + + it("should select a random bold colorway when dark", () => { + window.matchMedia.returns({ matches: true }); + COLORWAY_SCREEN_PROPS.content.tiles.darkVariation = 1; + + const { value } = test(); + + assert.strictEqual(value, "abstract-bold"); + assert.calledThrice(React.useEffect); + assert.calledOnce(window.matchMedia); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx new file mode 100644 index 0000000000..e3ed5d2bb0 --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/MobileDownloads.test.jsx @@ -0,0 +1,69 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; +import { GlobalOverrider } from "test/unit/utils"; +import { MobileDownloads } from "content-src/aboutwelcome/components/MobileDownloads"; + +describe("Multistage AboutWelcome MobileDownloads module", () => { + let globals; + let sandbox; + + beforeEach(async () => { + globals = new GlobalOverrider(); + globals.set({ + AWFinish: () => Promise.resolve(), + AWSendToDeviceEmailsSupported: () => Promise.resolve(), + }); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("Mobile Downloads component", () => { + const MOBILE_DOWNLOADS_PROPS = { + data: { + QR_code: { + image_url: + "chrome://browser/components/privatebrowsing/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + email: { + link_text: "Email yourself a link", + }, + marketplace_buttons: ["ios", "android"], + }, + handleAction: () => { + window.AWFinish(); + }, + }; + + it("should render MobileDownloads", () => { + const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should handle action on markeplace badge click", () => { + const wrapper = mount(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + const stub = sandbox.stub(global, "AWFinish"); + wrapper.find(".ios button").simulate("click"); + wrapper.find(".android button").simulate("click"); + + assert.calledTwice(stub); + }); + + it("should handle action on email button click", () => { + const wrapper = shallow(<MobileDownloads {...MOBILE_DOWNLOADS_PROPS} />); + + const stub = sandbox.stub(global, "AWFinish"); + wrapper.find("button.email-link").simulate("click"); + + assert.calledOnce(stub); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx new file mode 100644 index 0000000000..cb1ce3651a --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/MultiSelect.test.jsx @@ -0,0 +1,151 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; +import { MultiSelect } from "content-src/aboutwelcome/components/MultiSelect"; + +describe("Multistage AboutWelcome module", () => { + let sandbox; + let MULTISELECT_SCREEN_PROPS; + let setActiveMultiSelect; + beforeEach(() => { + sandbox = sinon.createSandbox(); + setActiveMultiSelect = sandbox.stub(); + MULTISELECT_SCREEN_PROPS = { + id: "multiselect-screen", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + image_alt_text: { + string_id: "mr2022-onboarding-default-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: "Test Title", + subtitle: "Test SubTitle", + tiles: { + type: "multiselect", + data: [ + { + id: "checkbox-1", + defaultValue: true, + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + type: "SET_DEFAULT_BROWSER", + }, + }, + { + id: "checkbox-2", + defaultValue: true, + label: "Test Checkbox 2", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + }, + { + id: "checkbox-3", + defaultValue: false, + label: "Test Checkbox 3", + action: { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + }, + ], + }, + primary_button: { + label: "Save and Continue", + action: { + type: "MULTI_ACTION", + collectSelect: true, + navigate: true, + data: { actions: [] }, + }, + }, + secondary_button: { + label: "Skip", + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + + describe("MultiSelect component", () => { + it("should call setActiveMultiSelect with ids of checkboxes with defaultValue true", () => { + const wrapper = mount( + <MultiSelect + setActiveMultiSelect={setActiveMultiSelect} + {...MULTISELECT_SCREEN_PROPS} + /> + ); + + wrapper.setProps({ activeMultiSelect: null }); + assert.calledOnce(setActiveMultiSelect); + assert.calledWith(setActiveMultiSelect, ["checkbox-1", "checkbox-2"]); + }); + + it("should use activeMultiSelect ids to set checked state for respective checkbox", () => { + const wrapper = mount( + <MultiSelect + setActiveMultiSelect={setActiveMultiSelect} + {...MULTISELECT_SCREEN_PROPS} + /> + ); + + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + const checkBoxes = wrapper.find(".checkbox-container input"); + assert.strictEqual(checkBoxes.length, 3); + + assert.strictEqual(checkBoxes.first().props().checked, true); + assert.strictEqual(checkBoxes.at(1).props().checked, true); + assert.strictEqual(checkBoxes.last().props().checked, false); + }); + + it("should filter out id when checkbox is unchecked", () => { + const wrapper = shallow( + <MultiSelect + setActiveMultiSelect={setActiveMultiSelect} + {...MULTISELECT_SCREEN_PROPS} + /> + ); + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + + const ckbx1 = wrapper.find(".checkbox-container input").at(0); + assert.strictEqual(ckbx1.prop("value"), "checkbox-1"); + ckbx1.simulate("change", { + currentTarget: { value: "checkbox-1", checked: false }, + }); + assert.calledWith(setActiveMultiSelect, ["checkbox-2"]); + }); + + it("should add id when checkbox is checked", () => { + const wrapper = shallow( + <MultiSelect + setActiveMultiSelect={setActiveMultiSelect} + {...MULTISELECT_SCREEN_PROPS} + /> + ); + wrapper.setProps({ activeMultiSelect: ["checkbox-1", "checkbox-2"] }); + + const ckbx3 = wrapper.find(".checkbox-container input").at(2); + assert.strictEqual(ckbx3.prop("value"), "checkbox-3"); + ckbx3.simulate("change", { + currentTarget: { value: "checkbox-3", checked: true }, + }); + assert.calledWith(setActiveMultiSelect, [ + "checkbox-1", + "checkbox-2", + "checkbox-3", + ]); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx new file mode 100644 index 0000000000..22070101cf --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAWProton.test.jsx @@ -0,0 +1,564 @@ +import { AboutWelcomeDefaults } from "aboutwelcome/lib/AboutWelcomeDefaults.jsm"; +import { MultiStageProtonScreen } from "content-src/aboutwelcome/components/MultiStageProtonScreen"; +import { AWScreenUtils } from "lib/AWScreenUtils.jsm"; +import React from "react"; +import { mount } from "enzyme"; + +describe("MultiStageAboutWelcomeProton module", () => { + let sandbox; + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + }); + + describe("MultiStageAWProton component", () => { + it("should render MultiStageProton Screen", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + }); + + it("should render secondary section for split positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + hero_text: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal( + wrapper.find(".section-secondary h1").text(), + "test subtitle" + ); + assert.equal(wrapper.find("main").prop("pos"), "split"); + }); + + it("should render secondary section with content background for split positioned screens", () => { + const BACKGROUND_URL = + "chrome://activity-stream/content/data/content/assets/confetti.svg"; + const SCREEN_PROPS = { + content: { + position: "split", + background: `url(${BACKGROUND_URL}) var(--mr-secondary-position) no-repeat`, + split_narrow_bkg_position: "10px", + title: "test title", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.ok( + wrapper + .find("div.section-secondary") + .prop("style") + .background.includes("--mr-secondary-position") + ); + assert.ok( + wrapper.find("div.section-secondary").prop("style")[ + "--mr-secondary-background-position-y" + ], + "10px" + ); + }); + + it("should render with secondary section for split positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + hero_text: "test subtitle", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal( + wrapper.find(".section-secondary h1").text(), + "test subtitle" + ); + assert.equal(wrapper.find("main").prop("pos"), "split"); + }); + + it("should render with no secondary section for center positioned screens", () => { + const SCREEN_PROPS = { + content: { + position: "center", + title: "test title", + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".section-secondary").exists(), false); + assert.equal(wrapper.find(".welcome-text h1").text(), "test title"); + assert.equal(wrapper.find("main").prop("pos"), "center"); + }); + + it("should not render multiple action buttons if an additional button does not exist", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isFalse(wrapper.find(".additional-cta").exists()); + }); + + it("should render an additional action button with primary styling if no style has been specified", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.isTrue(wrapper.find(".additional-cta.primary").exists()); + }); + + it("should render an additional action button with secondary styling", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "secondary", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.secondary").exists(), true); + }); + + it("should render an additional action button with primary styling", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "primary", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.primary").exists(), true); + }); + + it("should render an additional action with link styling", () => { + const SCREEN_PROPS = { + content: { + position: "split", + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "link", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".additional-cta.cta-link").exists(), true); + }); + + it("should render an additional button with vertical orientation", () => { + const SCREEN_PROPS = { + content: { + position: "center", + title: "test title", + primary_button: { + label: "test primary button", + }, + additional_button: { + label: "test additional button", + style: "secondary", + flow: "column", + }, + }, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal( + wrapper.find(".additional-cta-container[flow='column']").exists(), + true + ); + }); + + it("should not render a progress bar if there is 1 step", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + progress_bar: true, + }, + isSingleScreen: true, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".steps.progress-bar").exists(), false); + }); + + it("should render a progress bar if there are 2 steps", () => { + const SCREEN_PROPS = { + content: { + title: "test title", + progress_bar: true, + }, + totalNumberOfScreens: 2, + }; + const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + assert.equal(wrapper.find(".steps.progress-bar").exists(), true); + }); + }); + + describe("AboutWelcomeDefaults for proton", () => { + const getData = () => AboutWelcomeDefaults.getDefaults(); + + async function prepConfig(config, evalFalseScreenIds) { + let data = await getData(); + + if (evalFalseScreenIds?.length) { + data.screens.forEach(async screen => { + if (evalFalseScreenIds.includes(screen.id)) { + screen.targeting = false; + } + }); + data.screens = await AWScreenUtils.evaluateTargetingAndRemoveScreens( + data.screens + ); + } + + return AboutWelcomeDefaults.prepareContentForReact({ + ...data, + ...config, + }); + } + beforeEach(() => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + sandbox.stub(AWScreenUtils, "evaluateScreenTargeting").returnsArg(0); + // This is necessary because there are still screens being removed with + // `removeScreens` in `prepareContentForReact()`. Once we've migrated + // to using screen targeting instead of manually removing screens, + // we can remove this stub. + sandbox + .stub(global.AWScreenUtils, "removeScreens") + .callsFake((screens, callback) => + AWScreenUtils.removeScreens(screens, callback) + ); + }); + it("should have 'pin' button by default", async () => { + const data = await prepConfig({ needPin: true }, [ + "AW_EASY_SETUP", + "AW_WELCOME_BACK", + ]); + assert.propertyVal( + data.screens[0].content.primary_button.action, + "type", + "PIN_FIREFOX_TO_TASKBAR" + ); + }); + it("should have 'pin' button if we need default and pin", async () => { + const data = await prepConfig( + { + needDefault: true, + needPin: true, + }, + ["AW_EASY_SETUP", "AW_WELCOME_BACK"] + ); + + assert.propertyVal( + data.screens[0].content.primary_button.action, + "type", + "PIN_FIREFOX_TO_TASKBAR" + ); + assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX"); + assert.propertyVal(data.screens[1], "id", "AW_SET_DEFAULT"); + assert.lengthOf(data.screens, getData().screens.length - 3); + }); + it("should keep 'pin' and remove 'default' if already default", async () => { + const data = await prepConfig({ needPin: true }, [ + "AW_EASY_SETUP", + "AW_WELCOME_BACK", + ]); + + assert.propertyVal(data.screens[0], "id", "AW_PIN_FIREFOX"); + assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); + assert.lengthOf(data.screens, getData().screens.length - 4); + }); + it("should switch to 'default' if already pinned", async () => { + const data = await prepConfig({ needDefault: true }, [ + "AW_EASY_SETUP", + "AW_WELCOME_BACK", + ]); + + assert.propertyVal(data.screens[0], "id", "AW_ONLY_DEFAULT"); + assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); + assert.lengthOf(data.screens, getData().screens.length - 4); + }); + it("should switch to 'start' if already pinned and default", async () => { + const data = await prepConfig({}, ["AW_EASY_SETUP", "AW_WELCOME_BACK"]); + + assert.propertyVal(data.screens[0], "id", "AW_GET_STARTED"); + assert.propertyVal(data.screens[1], "id", "AW_IMPORT_SETTINGS"); + assert.lengthOf(data.screens, getData().screens.length - 4); + }); + it("should have a FxA button", async () => { + const data = await prepConfig({}, ["AW_WELCOME_BACK"]); + + assert.notProperty(data, "skipFxA"); + assert.property(data.screens[0].content, "secondary_button_top"); + }); + it("should remove the FxA button if pref disabled", async () => { + global.Services.prefs.getBoolPref.returns(false); + + const data = await prepConfig(); + + assert.property(data, "skipFxA", true); + assert.notProperty(data.screens[0].content, "secondary_button_top"); + }); + it("should remove the caption if deleteIfNotEn is true", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").value("de"); + + const data = await prepConfig({ + id: "DEFAULT_ABOUTWELCOME_PROTON", + template: "multistage", + transitions: true, + background_url: + "chrome://activity-stream/content/data/content/assets/confetti.svg", + screens: [ + { + id: "AW_PIN_FIREFOX", + content: { + position: "corner", + help_text: { + deleteIfNotEn: true, + string_id: "mr1-onboarding-welcome-image-caption", + }, + }, + }, + ], + }); + + assert.notProperty(data.screens[0].content, "help_text"); + }); + }); + + describe("AboutWelcomeDefaults for MR split template proton", () => { + const getData = () => AboutWelcomeDefaults.getDefaults(true); + beforeEach(() => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + }); + + it("should use 'split' position template by default", async () => { + const data = await getData(); + assert.propertyVal(data.screens[0].content, "position", "split"); + }); + + it("should not include noodles by default", async () => { + const data = await getData(); + assert.notProperty(data.screens[0].content, "has_noodles"); + }); + }); + + describe("AboutWelcomeDefaults prepareMobileDownload", () => { + const TEST_CONTENT = { + screens: [ + { + id: "AW_MOBILE_DOWNLOAD", + content: { + title: "test", + hero_image: { + url: "https://example.com/test.svg", + }, + cta_paragraph: { + text: {}, + action: {}, + }, + }, + }, + ], + }; + it("should not set url for default qrcode svg", async () => { + sandbox.stub(global.AppConstants, "isChinaRepack").returns(false); + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal( + data.screens[0].content.hero_image, + "url", + "https://example.com/test.svg" + ); + }); + it("should set url for cn qrcode svg", async () => { + sandbox.stub(global.AppConstants, "isChinaRepack").returns(true); + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal( + data.screens[0].content.hero_image, + "url", + "https://example.com/test-cn.svg" + ); + }); + }); + + describe("AboutWelcomeDefaults prepareContentForReact", () => { + it("should not set action without screens", async () => { + const data = await AboutWelcomeDefaults.prepareContentForReact({ + ua: "test", + }); + + assert.propertyVal(data, "ua", "test"); + assert.notProperty(data, "screens"); + }); + it("should set action for import action", async () => { + const TEST_CONTENT = { + ua: "test", + screens: [ + { + id: "AW_IMPORT_SETTINGS", + content: { + primary_button: { + action: { + type: "SHOW_MIGRATION_WIZARD", + }, + }, + }, + }, + ], + }; + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal(data, "ua", "test"); + assert.propertyVal( + data.screens[0].content.primary_button.action.data, + "source", + "test" + ); + }); + it("should not set action if the action type != SHOW_MIGRATION_WIZARD", async () => { + const TEST_CONTENT = { + ua: "test", + screens: [ + { + id: "AW_IMPORT_SETTINGS", + content: { + primary_button: { + action: { + type: "SHOW_FIREFOX_ACCOUNTS", + data: {}, + }, + }, + }, + }, + ], + }; + const data = await AboutWelcomeDefaults.prepareContentForReact( + TEST_CONTENT + ); + assert.propertyVal(data, "ua", "test"); + assert.notPropertyVal( + data.screens[0].content.primary_button.action.data, + "source", + "test" + ); + }); + it("should remove theme screens on win7", async () => { + sandbox + .stub(global.AppConstants, "isPlatformAndVersionAtMost") + .returns(true); + sandbox + .stub(global.AWScreenUtils, "removeScreens") + .callsFake((screens, screen) => + AWScreenUtils.removeScreens(screens, screen) + ); + + const { screens } = await AboutWelcomeDefaults.prepareContentForReact({ + screens: [ + { + content: { + tiles: { type: "theme" }, + }, + }, + { id: "hello" }, + { + content: { + tiles: { type: "theme" }, + }, + }, + { id: "world" }, + ], + }); + + assert.deepEqual(screens, [{ id: "hello" }, { id: "world" }]); + }); + it("shouldn't remove colorway screens on win7", async () => { + sandbox + .stub(global.AppConstants, "isPlatformAndVersionAtMost") + .returns(true); + sandbox + .stub(global.AWScreenUtils, "removeScreens") + .callsFake((screens, screen) => + AWScreenUtils.removeScreens(screens, screen) + ); + + const { screens } = await AboutWelcomeDefaults.prepareContentForReact({ + screens: [ + { + content: { + tiles: { type: "colorway" }, + }, + }, + { id: "hello" }, + { + content: { + tiles: { type: "theme" }, + }, + }, + { id: "world" }, + ], + }); + + assert.deepEqual(screens, [ + { + content: { + tiles: { type: "colorway" }, + }, + }, + { id: "hello" }, + { id: "world" }, + ]); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx new file mode 100644 index 0000000000..d017f94b7f --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/MultiStageAboutWelcome.test.jsx @@ -0,0 +1,824 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { + MultiStageAboutWelcome, + SecondaryCTA, + StepsIndicator, + ProgressBar, + WelcomeScreen, +} from "content-src/aboutwelcome/components/MultiStageAboutWelcome"; +import { Themes } from "content-src/aboutwelcome/components/Themes"; +import React from "react"; +import { shallow, mount } from "enzyme"; +import { AboutWelcomeDefaults } from "aboutwelcome/lib/AboutWelcomeDefaults.jsm"; +import { AboutWelcomeUtils } from "content-src/lib/aboutwelcome-utils"; + +describe("MultiStageAboutWelcome module", () => { + let globals; + let sandbox; + + const DEFAULT_PROPS = { + defaultScreens: AboutWelcomeDefaults.getDefaults().screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + + beforeEach(async () => { + globals = new GlobalOverrider(); + globals.set({ + AWGetSelectedTheme: () => Promise.resolve("automatic"), + AWSendEventTelemetry: () => {}, + AWWaitForMigrationClose: () => Promise.resolve(), + AWSelectTheme: () => Promise.resolve(), + AWFinish: () => Promise.resolve(), + }); + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("MultiStageAboutWelcome functional component", () => { + it("should render MultiStageAboutWelcome", () => { + const wrapper = shallow(<MultiStageAboutWelcome {...DEFAULT_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should pass activeTheme and initialTheme props to WelcomeScreen", async () => { + let wrapper = mount(<MultiStageAboutWelcome {...DEFAULT_PROPS} />); + // Spin the event loop to allow the useEffect hooks to execute, + // any promises to resolve, and re-rendering to happen after the + // promises have updated the state/props + await new Promise(resolve => setTimeout(resolve, 0)); + // sync up enzyme's representation with the real DOM + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + assert.strictEqual(welcomeScreenWrapper.prop("activeTheme"), "automatic"); + assert.strictEqual( + welcomeScreenWrapper.prop("initialTheme"), + "automatic" + ); + }); + + it("should handle primary Action", () => { + const screens = [ + { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + label: "Test button", + action: { + navigate: true, + }, + }, + }, + }, + ]; + + const PRIMARY_ACTION_PROPS = { + defaultScreens: screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + + const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry"); + let wrapper = mount(<MultiStageAboutWelcome {...PRIMARY_ACTION_PROPS} />); + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + const btnPrimary = welcomeScreenWrapper.find(".primary"); + btnPrimary.simulate("click"); + assert.calledOnce(stub); + assert.equal( + stub.firstCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.equal(stub.firstCall.args[1], "primary_button"); + stub.restore(); + }); + + it("should autoAdvance on last screen and send appropriate telemetry", () => { + let clock = sinon.useFakeTimers(); + const screens = [ + { + auto_advance: "primary_button", + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + label: "Test Button", + action: { + navigate: true, + }, + }, + }, + }, + ]; + const AUTO_ADVANCE_PROPS = { + defaultScreens: screens, + metricsFlowUri: "http://localhost/", + message_id: "DEFAULT_ABOUTWELCOME", + utm_term: "default", + startScreen: 0, + }; + const wrapper = mount(<MultiStageAboutWelcome {...AUTO_ADVANCE_PROPS} />); + wrapper.update(); + const finishStub = sandbox.stub(global, "AWFinish"); + const telemetryStub = sinon.stub( + AboutWelcomeUtils, + "sendActionTelemetry" + ); + + assert.notCalled(finishStub); + clock.tick(20001); + assert.calledOnce(finishStub); + assert.calledOnce(telemetryStub); + assert.equal(telemetryStub.lastCall.args[2], "AUTO_ADVANCE"); + clock.restore(); + finishStub.restore(); + telemetryStub.restore(); + }); + + it("should send telemetry ping on collectSelect", () => { + const screens = [ + { + id: "EASY_SETUP_TEST", + content: { + tiles: { + type: "multiselect", + data: [ + { + id: "checkbox-1", + defaultValue: true, + }, + ], + }, + primary_button: { + label: "Test Button", + action: { + collectSelect: true, + }, + }, + }, + }, + ]; + const EASY_SETUP_PROPS = { + defaultScreens: screens, + message_id: "DEFAULT_ABOUTWELCOME", + startScreen: 0, + }; + const stub = sinon.stub(AboutWelcomeUtils, "sendActionTelemetry"); + let wrapper = mount(<MultiStageAboutWelcome {...EASY_SETUP_PROPS} />); + wrapper.update(); + + let welcomeScreenWrapper = wrapper.find(WelcomeScreen); + const btnPrimary = welcomeScreenWrapper.find(".primary"); + btnPrimary.simulate("click"); + assert.calledTwice(stub); + assert.equal( + stub.firstCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.equal(stub.firstCall.args[1], "primary_button"); + assert.equal( + stub.lastCall.args[0], + welcomeScreenWrapper.props().messageId + ); + assert.ok(stub.lastCall.args[1].includes("checkbox-1")); + assert.equal(stub.lastCall.args[2], "SELECT_CHECKBOX"); + stub.restore(); + }); + }); + + describe("WelcomeScreen component", () => { + describe("get started screen", () => { + const screen = AboutWelcomeDefaults.getDefaults().screens.find( + s => s.id === "AW_PIN_FIREFOX" + ); + + const GET_STARTED_SCREEN_PROPS = { + id: screen.id, + totalNumberOfScreens: 1, + content: screen.content, + messageId: `${DEFAULT_PROPS.message_id}_${screen.id}`, + UTMTerm: DEFAULT_PROPS.utm_term, + flowParams: null, + }; + + it("should render GetStarted Screen", () => { + const wrapper = shallow( + <WelcomeScreen {...GET_STARTED_SCREEN_PROPS} /> + ); + assert.ok(wrapper.exists()); + }); + + it("should render secondary.top button", () => { + let SCREEN_PROPS = { + content: { + title: "Step", + secondary_button_top: { + text: "test", + label: "test label", + }, + }, + position: "top", + }; + const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />); + assert.ok(wrapper.find("div.secondary-cta.top").exists()); + }); + + it("should render the arrow icon in the secondary button", () => { + let SCREEN_PROPS = { + content: { + title: "Step", + secondary_button: { + has_arrow_icon: true, + label: "test label", + }, + }, + }; + const wrapper = mount(<SecondaryCTA {...SCREEN_PROPS} />); + assert.ok(wrapper.find("button.arrow-icon").exists()); + }); + + it("should render steps indicator", () => { + let PROPS = { totalNumberOfScreens: 1 }; + const wrapper = mount(<StepsIndicator {...PROPS} />); + assert.ok(wrapper.find("div.indicator").exists()); + }); + + it("should assign the total number of screens and current screen to the aria-valuemax and aria-valuenow labels", () => { + const EXTRA_PROPS = { totalNumberOfScreens: 3, order: 1 }; + const wrapper = mount( + <WelcomeScreen {...GET_STARTED_SCREEN_PROPS} {...EXTRA_PROPS} /> + ); + const steps = wrapper.find(`div.steps`); + assert.ok(steps.exists()); + const { attributes } = steps.getDOMNode(); + assert.equal( + parseInt(attributes.getNamedItem("aria-valuemax").value, 10), + EXTRA_PROPS.totalNumberOfScreens + ); + assert.equal( + parseInt(attributes.getNamedItem("aria-valuenow").value, 10), + EXTRA_PROPS.order + 1 + ); + }); + + it("should render progress bar", () => { + let SCREEN_PROPS = { + step: 1, + previousStep: 0, + totalNumberOfScreens: 2, + }; + const wrapper = mount(<ProgressBar {...SCREEN_PROPS} />); + assert.ok(wrapper.find("div.indicator").exists()); + assert.propertyVal( + wrapper.find("div.indicator").prop("style"), + "--progress-bar-progress", + "50%" + ); + }); + + it("should have a primary, secondary and secondary.top button in the rendered input", () => { + const wrapper = mount(<WelcomeScreen {...GET_STARTED_SCREEN_PROPS} />); + assert.ok(wrapper.find(".primary").exists()); + assert.ok( + wrapper + .find(".secondary-cta button.secondary[value='secondary_button']") + .exists() + ); + assert.ok( + wrapper + .find( + ".secondary-cta.top button.secondary[value='secondary_button_top']" + ) + .exists() + ); + }); + }); + + describe("theme screen", () => { + const THEME_SCREEN_PROPS = { + id: "test-theme-screen", + totalNumberOfScreens: 1, + content: { + title: "test title", + subtitle: "test subtitle", + tiles: { + type: "theme", + action: { + theme: "<event>", + }, + data: [ + { + theme: "automatic", + label: "test-label", + tooltip: "test-tooltip", + description: "test-description", + }, + ], + }, + primary_button: { + action: {}, + label: "test button", + }, + }, + navigate: null, + messageId: `${DEFAULT_PROPS.message_id}_"test-theme-screen"`, + UTMTerm: DEFAULT_PROPS.utm_term, + flowParams: null, + activeTheme: "automatic", + }; + + it("should render WelcomeScreen", () => { + const wrapper = shallow(<WelcomeScreen {...THEME_SCREEN_PROPS} />); + + assert.ok(wrapper.exists()); + }); + + it("should check this.props.activeTheme in the rendered input", () => { + const wrapper = shallow(<Themes {...THEME_SCREEN_PROPS} />); + + const selectedThemeInput = wrapper.find(".theme input[checked=true]"); + assert.strictEqual( + selectedThemeInput.prop("value"), + THEME_SCREEN_PROPS.activeTheme + ); + }); + }); + describe("import screen", () => { + const IMPORT_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + help_text: { + text: "test help text", + position: "default", + }, + }, + }; + it("should render ImportScreen", () => { + const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />); + assert.ok(wrapper.exists()); + }); + it("should not have a primary or secondary button", () => { + const wrapper = mount(<WelcomeScreen {...IMPORT_SCREEN_PROPS} />); + assert.isFalse(wrapper.find(".primary").exists()); + assert.isFalse( + wrapper.find(".secondary button[value='secondary_button']").exists() + ); + assert.isFalse( + wrapper + .find(".secondary button[value='secondary_button_top']") + .exists() + ); + }); + }); + describe("#handleAction", () => { + let SCREEN_PROPS; + let TEST_ACTION; + beforeEach(() => { + SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: {}, + label: "test button", + }, + }, + navigate: sandbox.stub(), + setActiveTheme: sandbox.stub(), + UTMTerm: "you_tee_emm", + }; + TEST_ACTION = SCREEN_PROPS.content.primary_button.action; + sandbox.stub(AboutWelcomeUtils, "handleUserAction").resolves(); + }); + it("should handle navigate", () => { + TEST_ACTION.navigate = true; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledOnce(SCREEN_PROPS.navigate); + }); + it("should handle theme", () => { + TEST_ACTION.theme = "test"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(SCREEN_PROPS.setActiveTheme, "test"); + }); + it("should handle dismiss", () => { + SCREEN_PROPS.content.dismiss_button = { + action: { dismiss: true }, + }; + const finishStub = sandbox.stub(global, "AWFinish"); + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".dismiss-button").simulate("click"); + + assert.calledOnce(finishStub); + }); + it("should handle SHOW_FIREFOX_ACCOUNTS", () => { + TEST_ACTION.type = "SHOW_FIREFOX_ACCOUNTS"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + data: { + extraParams: { + utm_campaign: "firstrun", + utm_medium: "referral", + utm_source: "activity-stream", + utm_term: "you_tee_emm-screen", + }, + }, + type: "SHOW_FIREFOX_ACCOUNTS", + }); + }); + it("should handle SHOW_MIGRATION_WIZARD", () => { + TEST_ACTION.type = "SHOW_MIGRATION_WIZARD"; + const wrapper = mount(<WelcomeScreen {...SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "SHOW_MIGRATION_WIZARD", + }); + }); + it("should handle SHOW_MIGRATION_WIZARD INSIDE MULTI_ACTION", async () => { + const migrationCloseStub = sandbox.stub( + global, + "AWWaitForMigrationClose" + ); + const MULTI_ACTION_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + label: "test button", + }, + }, + navigate: sandbox.stub(), + }; + const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }); + // handleUserAction returns a Promise, so let's let the microtask queue + // flush so that anything waiting for the handleUserAction Promise to + // resolve can run. + await new Promise(resolve => queueMicrotask(resolve)); + assert.calledOnce(migrationCloseStub); + }); + + it("should handle SHOW_MIGRATION_WIZARD INSIDE NESTED MULTI_ACTION", async () => { + const migrationCloseStub = sandbox.stub( + global, + "AWWaitForMigrationClose" + ); + const MULTI_ACTION_SCREEN_PROPS = { + content: { + title: "test title", + subtitle: "test subtitle", + primary_button: { + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + ], + }, + }, + label: "test button", + }, + }, + navigate: sandbox.stub(), + }; + const wrapper = mount(<WelcomeScreen {...MULTI_ACTION_SCREEN_PROPS} />); + + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "SET_DEFAULT_BROWSER", + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + }, + { + type: "SHOW_MIGRATION_WIZARD", + data: {}, + }, + ], + }, + }, + ], + }, + }); + // handleUserAction returns a Promise, so let's let the microtask queue + // flush so that anything waiting for the handleUserAction Promise to + // resolve can run. + await new Promise(resolve => queueMicrotask(resolve)); + assert.calledOnce(migrationCloseStub); + }); + it("should unset prefs from unchecked checkboxes", () => { + const PREF_SCREEN_PROPS = { + content: { + title: "Checkboxes", + tiles: { + type: "multiselect", + data: [ + { + id: "checkbox-1", + label: "checkbox 1", + checkedAction: { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + value: true, + }, + }, + }, + uncheckedAction: { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + }, + }, + }, + }, + { + id: "checkbox-2", + label: "checkbox 2", + checkedAction: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "pref-b", + value: "pref-b", + }, + }, + }, + { + type: "SET_PREF", + data: { + pref: { + name: "pref-c", + value: 3, + }, + }, + }, + ], + }, + }, + uncheckedAction: { + type: "SET_PREF", + data: { + pref: { name: "pref-b" }, + }, + }, + }, + ], + }, + primary_button: { + label: "Set Prefs", + action: { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [], + }, + }, + }, + }, + navigate: sandbox.stub(), + setActiveMultiSelect: sandbox.stub(), + }; + + // No checkboxes checked. All prefs will be unset and pref-c will not be + // reset. + { + const wrapper = mount( + <WelcomeScreen {...PREF_SCREEN_PROPS} activeMultiSelect={[]} /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { type: "SET_PREF", data: { pref: { name: "pref-a" } } }, + { type: "SET_PREF", data: { pref: { name: "pref-b" } } }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // The first checkbox is checked. Only pref-a will be set and pref-c + // will not be reset. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-1"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "pref-a", + value: true, + }, + }, + }, + { type: "SET_PREF", data: { pref: { name: "pref-b" } } }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // The second checkbox is checked. Prefs pref-b and pref-c will be set. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-2"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { type: "SET_PREF", data: { pref: { name: "pref-a" } } }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-b", value: "pref-b" } }, + }, + { + type: "SET_PREF", + data: { pref: { name: "pref-c", value: 3 } }, + }, + ], + }, + }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + + // // Both checkboxes are checked. All prefs will be set. + { + const wrapper = mount( + <WelcomeScreen + {...PREF_SCREEN_PROPS} + activeMultiSelect={["checkbox-1", "checkbox-2"]} + /> + ); + wrapper.find(".primary").simulate("click"); + assert.calledWith(AboutWelcomeUtils.handleUserAction, { + type: "MULTI_ACTION", + collectSelect: true, + isDynamic: true, + navigate: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-a", value: true } }, + }, + { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { pref: { name: "pref-b", value: "pref-b" } }, + }, + { + type: "SET_PREF", + data: { pref: { name: "pref-c", value: 3 } }, + }, + ], + }, + }, + ], + }, + }); + + AboutWelcomeUtils.handleUserAction.resetHistory(); + } + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx b/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx new file mode 100644 index 0000000000..db6d8ba10a --- /dev/null +++ b/browser/components/newtab/test/unit/aboutwelcome/OnboardingVideoTest.test.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import { mount } from "enzyme"; +import { OnboardingVideo } from "content-src/aboutwelcome/components/OnboardingVideo"; + +describe("OnboardingVideo component", () => { + let sandbox; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const SCREEN_PROPS = { + content: { + title: "Test title", + video_container: { + video_url: "test url", + }, + }, + }; + + it("should handle video_start action when video is played", () => { + const handleAction = sandbox.stub(); + const wrapper = mount( + <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} /> + ); + wrapper.find("video").simulate("play"); + assert.calledWith(handleAction, { + currentTarget: { value: "video_start" }, + }); + }); + it("should handle video_end action when video has completed playing", () => { + const handleAction = sandbox.stub(); + const wrapper = mount( + <OnboardingVideo handleAction={handleAction} {...SCREEN_PROPS} /> + ); + wrapper.find("video").simulate("ended"); + assert.calledWith(handleAction, { + currentTarget: { value: "video_end" }, + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js new file mode 100644 index 0000000000..732200b408 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js @@ -0,0 +1,3040 @@ +import { _ASRouter, MessageLoaderUtils } from "lib/ASRouter.jsm"; +import { QueryCache } from "lib/ASRouterTargeting.jsm"; +import { + FAKE_LOCAL_MESSAGES, + FAKE_LOCAL_PROVIDER, + FAKE_LOCAL_PROVIDERS, + FAKE_REMOTE_MESSAGES, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +} from "./constants"; +import { + ASRouterPreferences, + TARGETING_PREFERENCES, +} from "lib/ASRouterPreferences.jsm"; +import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm"; +import { CFRPageActions } from "lib/CFRPageActions.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs"; +import ProviderResponseSchema from "content-src/asrouter/schemas/provider-response.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +const MESSAGE_PROVIDER_PREF_NAME = + "browser.newtabpage.activity-stream.asrouter.providers.snippets"; +const FAKE_PROVIDERS = [ + FAKE_LOCAL_PROVIDER, + FAKE_REMOTE_PROVIDER, + FAKE_REMOTE_SETTINGS_PROVIDER, +]; +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; +const FAKE_RESPONSE_HEADERS = { get() {} }; +const FAKE_BUNDLE = [FAKE_LOCAL_MESSAGES[1], FAKE_LOCAL_MESSAGES[2]]; + +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// eslint-disable-next-line max-statements +describe("ASRouter", () => { + let Router; + let globals; + let sandbox; + let initParams; + let messageBlockList; + let providerBlockList; + let messageImpressions; + let groupImpressions; + let previousSessionEnd; + let fetchStub; + let clock; + let getStringPrefStub; + let fakeAttributionCode; + let fakeTargetingContext; + let FakeToolbarBadgeHub; + let FakeToolbarPanelHub; + let FakeMomentsPageHub; + let ASRouterTargeting; + let screenImpressions; + + function setMessageProviderPref(value) { + sandbox.stub(ASRouterPreferences, "providers").get(() => value); + } + + function initASRouter(router) { + const getStub = sandbox.stub(); + getStub.returns(Promise.resolve()); + getStub + .withArgs("messageBlockList") + .returns(Promise.resolve(messageBlockList)); + getStub + .withArgs("providerBlockList") + .returns(Promise.resolve(providerBlockList)); + getStub + .withArgs("messageImpressions") + .returns(Promise.resolve(messageImpressions)); + getStub.withArgs("groupImpressions").resolves(groupImpressions); + getStub + .withArgs("previousSessionEnd") + .returns(Promise.resolve(previousSessionEnd)); + getStub + .withArgs("screenImpressions") + .returns(Promise.resolve(screenImpressions)); + initParams = { + storage: { + get: getStub, + set: sandbox.stub().returns(Promise.resolve()), + }, + sendTelemetry: sandbox.stub().resolves(), + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + dispatchCFRAction: sandbox.stub().resolves(), + }; + sandbox.stub(router, "loadMessagesFromAllProviders").callThrough(); + return router.init(initParams); + } + + async function createRouterAndInit(providers = FAKE_PROVIDERS) { + setMessageProviderPref(providers); + // `.freeze` to catch any attempts at modifying the object + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + await initASRouter(Router); + } + + beforeEach(async () => { + globals = new GlobalOverrider(); + messageBlockList = []; + providerBlockList = []; + messageImpressions = {}; + groupImpressions = {}; + previousSessionEnd = 100; + screenImpressions = {}; + sandbox = sinon.createSandbox(); + ASRouterTargeting = { + isMatch: sandbox.stub(), + findMatchingMessage: sandbox.stub(), + Environment: { + locale: "en-US", + localeLanguageCode: "en", + browserSettings: { + update: { + channel: "default", + enabled: true, + autoDownload: true, + }, + }, + attributionData: {}, + currentDate: "2000-01-01T10:00:0.001Z", + profileAgeCreated: {}, + profileAgeReset: {}, + usesFirefoxSync: false, + isFxAEnabled: true, + isFxASignedIn: false, + sync: { + desktopDevices: 0, + mobileDevices: 0, + totalDevices: 0, + }, + xpinstallEnabled: true, + addonsInfo: {}, + searchEngines: {}, + isDefaultBrowser: false, + devToolsOpenedCount: 5, + topFrecentSites: {}, + recentBookmarks: {}, + pinnedSites: [ + { + url: "https://amazon.com", + host: "amazon.com", + searchTopSite: true, + }, + ], + providerCohorts: { + onboarding: "", + cfr: "", + "message-groups": "", + "messaging-experiments": "", + snippets: "", + "whats-new-panel": "", + }, + totalBookmarksCount: {}, + firefoxVersion: 80, + region: "US", + needsUpdate: {}, + hasPinnedTabs: false, + hasAccessedFxAPanel: false, + isWhatsNewPanelEnabled: true, + userPrefs: { + cfrFeatures: true, + cfrAddons: true, + snippets: true, + }, + totalBlockedCount: {}, + blockedCountByType: {}, + attachedFxAOAuthClients: [], + platformName: "macosx", + scores: {}, + scoreThreshold: 5000, + isChinaRepack: false, + userId: "adsf", + }, + }; + + ASRouterPreferences.specialConditions = { + someCondition: true, + }; + sandbox.spy(ASRouterPreferences, "init"); + sandbox.spy(ASRouterPreferences, "uninit"); + sandbox.spy(ASRouterPreferences, "addListener"); + sandbox.spy(ASRouterPreferences, "removeListener"); + + clock = sandbox.useFakeTimers(); + fetchStub = sandbox + .stub(global, "fetch") + .withArgs("http://fake.com/endpoint") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: FAKE_REMOTE_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + + fakeAttributionCode = { + allowedCodeKeys: ["foo", "bar", "baz"], + _clearCache: () => sinon.stub(), + getAttrDataAsync: () => Promise.resolve({ content: "addonID" }), + deleteFileAsync: () => Promise.resolve(), + writeAttributionFile: () => Promise.resolve(), + getCachedAttributionData: sinon.stub(), + }; + FakeToolbarPanelHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + forceShowMessage: sandbox.stub(), + enableToolbarButton: sandbox.stub(), + }; + FakeToolbarBadgeHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + registerBadgeNotificationListener: sandbox.stub(), + }; + FakeMomentsPageHub = { + init: sandbox.stub(), + uninit: sandbox.stub(), + executeAction: sandbox.stub(), + }; + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + }; + let fakeNimbusFeatures = [ + "cfr", + "infobar", + "spotlight", + "moments-page", + "pbNewtab", + ].reduce((features, featureId) => { + features[featureId] = { + getAllVariables: sandbox.stub().returns(null), + recordExposureEvent: sandbox.stub(), + }; + return features; + }, {}); + globals.set({ + // Testing framework doesn't know how to `defineLazyModuleGetter` so we're + // importing these modules into the global scope ourselves. + GroupsConfigurationProvider: { getMessages: () => Promise.resolve([]) }, + ASRouterPreferences, + TARGETING_PREFERENCES, + ASRouterTargeting, + ASRouterTriggerListeners, + QueryCache, + gBrowser: { selectedBrowser: {} }, + gURLBar: {}, + isSeparateAboutWelcome: true, + AttributionCode: fakeAttributionCode, + SnippetsTestMessageProvider, + PanelTestProvider, + MacAttribution: { applicationPath: "" }, + ToolbarBadgeHub: FakeToolbarBadgeHub, + ToolbarPanelHub: FakeToolbarPanelHub, + MomentsPageHub: FakeMomentsPageHub, + KintoHttpClient: class { + bucket() { + return this; + } + collection() { + return this; + } + getRecord() { + return Promise.resolve({ data: {} }); + } + }, + Downloader: class { + download() { + return Promise.resolve("/path/to/download"); + } + }, + NimbusFeatures: fakeNimbusFeatures, + ExperimentAPI: { + getExperimentMetaData: sandbox.stub().returns({ + slug: "experiment-slug", + active: true, + branch: { slug: "experiment-branch-slug" }, + }), + getExperiment: sandbox.stub().returns({ + branch: { + slug: "unit-slug", + feature: { + featureId: "foo", + value: { id: "test-message" }, + }, + }, + }), + getAllBranches: sandbox.stub().resolves([]), + ready: sandbox.stub().resolves(), + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + TargetingContext: class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + }, + RemoteL10n: { + // This is just a subset of supported locales that happen to be used in + // the test. + isLocaleSupported: locale => ["en-US", "ja-JP-mac"].includes(locale), + }, + }); + await createRouterAndInit(); + }); + afterEach(() => { + Router.uninit(); + ASRouterPreferences.uninit(); + sandbox.restore(); + globals.restore(); + }); + + describe(".state", () => { + it("should throw if an attempt to set .state was made", () => { + assert.throws(() => { + Router.state = {}; + }); + }); + }); + + describe("#init", () => { + it("should only be called once", async () => { + Router = new _ASRouter(); + let state = await initASRouter(Router); + + assert.equal(state, Router.state); + + assert.isNull(await Router.init({})); + }); + it("should only be called once", async () => { + Router = new _ASRouter(); + initASRouter(Router); + let secondCall = await Router.init({}); + + assert.isNull( + secondCall, + "Should not init twice, it should exit early with null" + ); + }); + it("should set state.messageBlockList to the block list in persistent storage", async () => { + messageBlockList = ["foo"]; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageBlockList, ["foo"]); + }); + it("should initialize all the hub providers", async () => { + // ASRouter init called in `beforeEach` block above + + assert.calledOnce(FakeToolbarBadgeHub.init); + assert.calledOnce(FakeToolbarPanelHub.init); + assert.calledOnce(FakeMomentsPageHub.init); + + assert.calledWithExactly( + FakeToolbarBadgeHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + unblockMessageById: Router.unblockMessageById, + } + ); + + assert.calledWithExactly( + FakeToolbarPanelHub.init, + Router.waitForInitialized, + { + getMessages: Router.handleMessageRequest, + sendTelemetry: Router.sendTelemetry, + } + ); + + assert.calledWithExactly( + FakeMomentsPageHub.init, + Router.waitForInitialized, + { + handleMessageRequest: Router.handleMessageRequest, + addImpression: Router.addImpression, + blockMessageById: Router.blockMessageById, + sendTelemetry: Router.sendTelemetry, + } + ); + }); + it("should set state.messageImpressions to the messageImpressions object in persistent storage", async () => { + // Note that messageImpressions are only kept if a message exists in router and has a .frequency property, + // otherwise they will be cleaned up by .cleanupImpressions() + const testMessage = { id: "foo", frequency: { lifetimeCap: 10 } }; + messageImpressions = { foo: [0, 1, 2] }; + setMessageProviderPref([ + { id: "onboarding", type: "local", messages: [testMessage] }, + ]); + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + it("should set state.screenImpressions to the screenImpressions object in persistent storage", async () => { + screenImpressions = { test: 123 }; + + Router = new _ASRouter(); + await initASRouter(Router); + + assert.deepEqual(Router.state.screenImpressions, screenImpressions); + }); + it("should clear impressions for groups that are not active", async () => { + groupImpressions = { foo: [0, 1, 2] }; + Router = new _ASRouter(); + await initASRouter(Router); + + assert.notProperty(Router.state.groupImpressions, "foo"); + }); + it("should keep impressions for groups that are active", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + lifetime: Infinity, + }, + }, + ], + groupImpressions: { foo: [Date.now()] }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should remove old impressions for a group", async () => { + Router = new _ASRouter(); + await initASRouter(Router); + await Router.setState(() => { + return { + groups: [ + { + id: "foo", + enabled: true, + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 10 }], + }, + }, + ], + groupImpressions: { + foo: [Date.now() - ONE_DAY_IN_MS - 1, Date.now()], + }, + }; + }); + Router.cleanupImpressions(); + + assert.property(Router.state.groupImpressions, "foo"); + assert.lengthOf(Router.state.groupImpressions.foo, 1); + }); + it("should await .loadMessagesFromAllProviders() and add messages from providers to state.messages", async () => { + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + + await initASRouter(Router); + + assert.calledOnce(Router.loadMessagesFromAllProviders); + assert.isArray(Router.state.messages); + assert.lengthOf( + Router.state.messages, + FAKE_LOCAL_MESSAGES.length + FAKE_REMOTE_MESSAGES.length + ); + }); + it("should load additional allowed hosts", async () => { + getStringPrefStub.returns('["allow.com"]'); + await createRouterAndInit(); + + assert.propertyVal(Router.ALLOWLIST_HOSTS, "allow.com", "preview"); + // Should still include the defaults + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 3); + }); + it("should fallback to defaults if pref parsing fails", async () => { + getStringPrefStub.returns("err"); + await createRouterAndInit(); + + assert.lengthOf(Object.keys(Router.ALLOWLIST_HOSTS), 2); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "snippets-admin.mozilla.org", + "preview" + ); + assert.propertyVal( + Router.ALLOWLIST_HOSTS, + "activity-stream-icons.services.mozilla.com", + "production" + ); + }); + it("should set state.previousSessionEnd from IndexedDB", async () => { + previousSessionEnd = 200; + await createRouterAndInit(); + + assert.equal(Router.state.previousSessionEnd, previousSessionEnd); + }); + it("should assign ASRouterPreferences.specialConditions to state", async () => { + assert.isTrue(ASRouterPreferences.specialConditions.someCondition); + assert.isTrue(Router.state.someCondition); + }); + it("should add observer for `intl:app-locales-changed`", async () => { + sandbox.spy(global.Services.obs, "addObserver"); + await createRouterAndInit(); + + assert.calledWithExactly( + global.Services.obs.addObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should add a pref observer", async () => { + sandbox.spy(global.Services.prefs, "addObserver"); + await createRouterAndInit(); + + assert.calledOnce(global.Services.prefs.addObserver); + assert.calledWithExactly( + global.Services.prefs.addObserver, + USE_REMOTE_L10N_PREF, + Router + ); + }); + describe("lazily loading local test providers", () => { + afterEach(() => { + Router.uninit(); + }); + it("should add the local test providers on init if devtools are enabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + + await createRouterAndInit(); + + assert.property(Router._localProviders, "SnippetsTestMessageProvider"); + assert.property(Router._localProviders, "PanelTestProvider"); + }); + it("should not add the local test providers on init if devtools are disabled", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + + await createRouterAndInit(); + + assert.notProperty( + Router._localProviders, + "SnippetsTestMessageProvider" + ); + assert.notProperty(Router._localProviders, "PanelTestProvider"); + }); + }); + }); + + describe("preference changes", () => { + it("should call ASRouterPreferences.init and add a listener on init", () => { + assert.calledOnce(ASRouterPreferences.init); + assert.calledWith(ASRouterPreferences.addListener, Router.onPrefChange); + }); + it("should call ASRouterPreferences.uninit and remove the listener on uninit", () => { + Router.uninit(); + assert.calledOnce(ASRouterPreferences.uninit); + assert.calledWith( + ASRouterPreferences.removeListener, + Router.onPrefChange + ); + }); + it("should send a AS_ROUTER_TARGETING_UPDATE message", async () => { + const messageTargeted = { + id: "1", + campaign: "foocampaign", + targeting: "true", + groups: ["snippets"], + provider: "snippets", + }; + const messageNotTargeted = { + id: "2", + campaign: "foocampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [messageTargeted, messageNotTargeted], + providers: [{ id: "snippets" }], + }); + fakeTargetingContext.evalWithDefault.resolves(false); + + await Router.onPrefChange("services.sync.username"); + + assert.calledOnce(initParams.clearChildMessages); + assert.calledWith(initParams.clearChildMessages, [messageTargeted.id]); + }); + it("should call loadMessagesFromAllProviders on pref change", () => { + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should update groups state if a user pref changes", async () => { + await Router.setState({ + groups: [{ id: "foo", userPreferences: ["bar"], enabled: true }], + }); + sandbox.stub(ASRouterPreferences, "getUserPreference"); + + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledWithExactly(ASRouterPreferences.getUserPreference, "bar"); + }); + it("should update the list of providers on pref change", async () => { + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + url: "baz.com", + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + + const { length } = Router.state.providers; + + ASRouterPreferences.observe(null, null, MESSAGE_PROVIDER_PREF_NAME); + await Router._updateMessageProviders(); + + const provider = Router.state.providers.find(p => p.url === "baz.com"); + assert.lengthOf(Router.state.providers, length); + assert.isDefined(provider); + }); + it("should clear disabled providers on pref change", async () => { + const TEST_PROVIDER_ID = "some_provider_id"; + await Router.setState({ + providers: [{ id: TEST_PROVIDER_ID }], + }); + const modifiedRemoteProvider = Object.assign({}, FAKE_REMOTE_PROVIDER, { + id: TEST_PROVIDER_ID, + enabled: false, + }); + setMessageProviderPref([ + FAKE_LOCAL_PROVIDER, + modifiedRemoteProvider, + FAKE_REMOTE_SETTINGS_PROVIDER, + ]); + await Router.onPrefChange(MESSAGE_PROVIDER_PREF_NAME); + + assert.calledOnce(initParams.clearChildProviders); + assert.calledWith(initParams.clearChildProviders, [TEST_PROVIDER_ID]); + }); + }); + + describe("setState", () => { + it("should broadcast a message to update the admin tool on a state change if the asrouter.devtoolsEnabled pref is", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => true); + sandbox.stub(Router, "getTargetingParameters").resolves({}); + const state = await Router.setState({ foo: 123 }); + + assert.calledOnce(initParams.updateAdminState); + assert.deepEqual(state.providerPrefs, ASRouterPreferences.providers); + assert.deepEqual( + state.userPrefs, + ASRouterPreferences.getAllUserPreferences() + ); + assert.deepEqual(state.targetingParameters, {}); + assert.deepEqual(state.errors, Router.errors); + }); + it("should not send a message on a state change asrouter.devtoolsEnabled pref is on", async () => { + sandbox.stub(ASRouterPreferences, "devtoolsEnabled").get(() => false); + await Router.setState({ foo: 123 }); + + assert.notCalled(initParams.updateAdminState); + }); + }); + + describe("getTargetingParameters", () => { + it("should return the targeting parameters", async () => { + const stub = sandbox.stub().resolves("foo"); + const obj = { foo: 1 }; + sandbox.stub(obj, "foo").get(stub); + const result = await Router.getTargetingParameters(obj, obj); + + assert.calledTwice(stub); + assert.propertyVal(result, "foo", "foo"); + }); + }); + + describe("evaluateExpression", () => { + it("should call ASRouterTargeting to evaluate", async () => { + fakeTargetingContext.evalWithDefault.resolves("foo"); + const response = await Router.evaluateExpression({}); + assert.equal(response.evaluationStatus.result, "foo"); + assert.isTrue(response.evaluationStatus.success); + }); + it("should catch evaluation errors", async () => { + fakeTargetingContext.evalWithDefault.returns( + Promise.reject(new Error("fake error")) + ); + const response = await Router.evaluateExpression({}); + assert.isFalse(response.evaluationStatus.success); + }); + }); + + describe("#routeCFRMessage", () => { + let browser; + beforeEach(() => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + sandbox.stub(CFRPageActions, "addRecommendation"); + browser = {}; + }); + it("should route whatsnew_panel_message message to the right hub", () => { + Router.routeCFRMessage( + { template: "whatsnew_panel_message" }, + browser, + "", + true + ); + + assert.calledOnce(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route moments messages to the right hub", () => { + Router.routeCFRMessage({ template: "update_action" }, browser, "", true); + + assert.calledOnce(FakeMomentsPageHub.executeAction); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + }); + it("should route toolbar_badge message to the right hub", () => { + Router.routeCFRMessage({ template: "toolbar_badge" }, browser); + + assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route milestone_message to the right hub", () => { + Router.routeCFRMessage( + { template: "milestone_message" }, + browser, + "", + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_doorhanger" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_doorhanger message to the right hub force = true", () => { + Router.routeCFRMessage({ template: "cfr_doorhanger" }, browser, {}, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = false", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + { param: {} }, + false + ); + + assert.calledOnce(CFRPageActions.addRecommendation); + const { args } = CFRPageActions.addRecommendation.firstCall; + // Host should be null + assert.isNull(args[1]); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route cfr_urlbar_chiclet message to the right hub force = true", () => { + Router.routeCFRMessage( + { template: "cfr_urlbar_chiclet" }, + browser, + {}, + true + ); + + assert.calledOnce(CFRPageActions.forceRecommendation); + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + it("should route default to sending to content", () => { + Router.routeCFRMessage({ template: "snippets" }, browser, {}, true); + + assert.notCalled(FakeToolbarPanelHub.forceShowMessage); + assert.notCalled(CFRPageActions.forceRecommendation); + assert.notCalled(CFRPageActions.addRecommendation); + assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener); + assert.notCalled(FakeMomentsPageHub.executeAction); + }); + }); + + describe("#loadMessagesFromAllProviders", () => { + function assertRouterContainsMessages(messages) { + const messageIdsInRouter = Router.state.messages.map(m => m.id); + for (const message of messages) { + assert.include(messageIdsInRouter, message.id); + } + } + + it("should not trigger an update if not enough time has passed for a provider", async () => { + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + enabled: true, + url: "http://fake.com/endpoint", + updateCycleInMs: 300, + }, + ]); + + const previousState = Router.state; + + // Since we've previously gotten messages during init and we haven't advanced our fake timer, + // no updates should be triggered. + await Router.loadMessagesFromAllProviders(); + assert.deepEqual(Router.state, previousState); + }); + it("should not trigger an update if we only have local providers", async () => { + await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + + const previousState = Router.state; + const stub = sandbox.stub(MessageLoaderUtils, "loadMessagesForProvider"); + + clock.tick(300); + + await Router.loadMessagesFromAllProviders(); + + assert.deepEqual(Router.state, previousState); + assert.notCalled(stub); + }); + it("should update messages for a provider if enough time has passed, without removing messages for other providers", async () => { + const NEW_MESSAGES = [{ id: "new_123" }]; + await createRouterAndInit([ + { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, + updateCycleInMs: 300, + }, + { + id: "alocalprovider", + type: "local", + enabled: true, + messages: FAKE_LOCAL_MESSAGES, + }, + ]); + fetchStub.withArgs("http://fake.com/endpoint").resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: NEW_MESSAGES }), + headers: FAKE_RESPONSE_HEADERS, + }); + + clock.tick(301); + await Router.loadMessagesFromAllProviders(); + + // These are the new messages + assertRouterContainsMessages(NEW_MESSAGES); + // These are the local messages that should not have been deleted + assertRouterContainsMessages(FAKE_LOCAL_MESSAGES); + }); + it("should parse the triggers in the messages and register the trigger listeners", async () => { + sandbox.spy( + ASRouterTriggerListeners.get("openURL"), + "init" + ); /* eslint-disable object-property-newline */ + + /* eslint-disable object-curly-newline */ await createRouterAndInit([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "foo", + template: "simple_template", + trigger: { id: "firstRun" }, + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "bar1", + template: "simple_template", + trigger: { + id: "openURL", + params: ["www.mozilla.org", "www.mozilla.com"], + }, + content: { title: "Bar1", body: "Bar123" }, + }, + { + id: "bar2", + template: "simple_template", + trigger: { id: "openURL", params: ["www.example.com"] }, + content: { title: "Bar2", body: "Bar123" }, + }, + ], + }, + ]); /* eslint-enable object-property-newline */ + /* eslint-enable object-curly-newline */ assert.calledTwice( + ASRouterTriggerListeners.get("openURL").init + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.mozilla.org", "www.mozilla.com"], + undefined + ); + assert.calledWithExactly( + ASRouterTriggerListeners.get("openURL").init, + Router._triggerHandler, + ["www.example.com"], + undefined + ); + }); + it("should parse the message's messagesLoaded trigger and immediately fire trigger", async () => { + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + { + id: "bar3", + template: "simple_template", + trigger: { id: "messagesLoaded" }, + content: { title: "Bar3", body: "Bar123" }, + }, + ], + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle messages loading before a window or browser exists", async () => { + sandbox.stub(global, "gBrowser").value(undefined); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns(undefined); + setMessageProviderPref([ + { + id: "foo", + type: "local", + enabled: true, + messages: [ + "whatsnew_panel_message", + "cfr_doorhanger", + "toolbar_badge", + "update_action", + "infobar", + "spotlight", + "toast_notification", + ].map((template, i) => { + return { + id: `foo${i}`, + template, + trigger: { id: "messagesLoaded" }, + content: { title: `Foo${i}`, body: "Bar123" }, + }; + }), + }, + ]); + Router = new _ASRouter(Object.freeze(FAKE_LOCAL_PROVIDERS)); + sandbox.spy(Router, "sendTriggerMessage"); + await initASRouter(Router); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ id: "messagesLoaded" }), + true + ); + }); + it("should gracefully handle RemoteSettings blowing up and dispatch undesired event", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .rejects("fake error"); + await createRouterAndInit(); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_ERROR", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should dispatch undesired event if RemoteSettings returns no messages", async () => { + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([]); + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "remotey-settingsy", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + it("should download the attachment if RemoteSettings returns some messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + const spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledOnce(spy); + }); + it("should dispatch undesired event if the ms-language-packs returns no messages", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + sandbox + .stub(global.KintoHttpClient.prototype, "getRecord") + .resolves(null); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + + assert.calledWith(initParams.dispatchCFRAction, { + data: { + action: "asrouter_undesired_event", + event: "ASR_RS_NO_MESSAGES", + event_context: "ms-language-packs", + message_id: "n/a", + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + }); + }); + }); + + describe("#_updateMessageProviders", () => { + it("should correctly replace %STARTPAGE_VERSION% in remote provider urls", async () => { + // If this test fails, you need to update the constant STARTPAGE_VERSION in + // ASRouter.jsm to match the `version` property of provider-response-schema.json + const expectedStartpageVersion = ProviderResponseSchema.version; + const provider = { + id: "foo", + enabled: true, + type: "remote", + url: "https://www.mozilla.org/%STARTPAGE_VERSION%/", + }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.equal( + Router.state.providers[0].url, + `https://www.mozilla.org/${parseInt(expectedStartpageVersion, 10)}/` + ); + }); + it("should replace other params in remote provider urls by calling Services.urlFormater.formatURL", async () => { + const url = "https://www.example.com/"; + const replacedUrl = "https://www.foo.bar/"; + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .withArgs(url) + .returns(replacedUrl); + const provider = { id: "foo", enabled: true, type: "remote", url }; + setMessageProviderPref([provider]); + await Router._updateMessageProviders(); + assert.calledOnce(stub); + assert.calledWithExactly(stub, url); + assert.equal(Router.state.providers[0].url, replacedUrl); + }); + it("should only add the providers that are enabled", async () => { + const providers = [ + { + id: "foo", + enabled: false, + type: "remote", + url: "https://www.foo.com/", + }, + { + id: "bar", + enabled: true, + type: "remote", + url: "https://www.bar.com/", + }, + ]; + setMessageProviderPref(providers); + await Router._updateMessageProviders(); + assert.equal(Router.state.providers.length, 1); + assert.equal(Router.state.providers[0].id, providers[1].id); + }); + }); + + describe("#handleMessageRequest", () => { + beforeEach(async () => { + await Router.setState(() => ({ + providers: [{ id: "snippets" }, { id: "badge" }], + })); + }); + it("should not return a blocked message", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foo"], + })); + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message from a disabled group", async () => { + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { id: "foo", provider: "snippets", groups: ["snippets"] }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + groups: [{ id: "snippets", enabled: false }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message from a blocked campaign", async () => { + // Block all messages except the first + await Router.setState(() => ({ + messages: [ + { + id: "foo", + provider: "snippets", + campaign: "foocampaign", + groups: ["snippets"], + }, + { id: "bar", provider: "snippets", groups: ["snippets"] }, + ], + messageBlockList: ["foocampaign"], + })); + + await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.calledWithMatch(ASRouterTargeting.findMatchingMessage, { + messages: [{ id: "bar", provider: "snippets", groups: ["snippets"] }], + }); + }); + it("should not return a message excluded by the provider", async () => { + // There are only two providers; block the FAKE_LOCAL_PROVIDER, leaving + // only FAKE_REMOTE_PROVIDER unblocked, which provides only one message + await Router.setState(() => ({ + providers: [{ id: "snippets", exclude: ["foo"] }], + })); + + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + messageBlockList: ["foocampaign"], + })); + + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should not return a message if the frequency cap has been hit", async () => { + sandbox.stub(Router, "isBelowFrequencyCaps").returns(false); + await Router.setState(() => ({ + messages: [{ id: "foo", provider: "snippets" }], + })); + const result = await Router.handleMessageRequest({ + provider: "snippets", + }); + assert.isNull(result); + }); + it("should get unblocked messages that match the trigger", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ triggerId: "foo" }); + + assert.deepEqual(result, message1); + }); + it("should get unblocked messages that match trigger and template", async () => { + const message1 = { + id: "1", + campaign: "foocampaign", + template: "badge", + trigger: { id: "foo" }, + groups: ["badge"], + provider: "badge", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + template: "snippet", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + const result = Router.handleMessageRequest({ + triggerId: "foo", + template: "badge", + }); + + assert.deepEqual(result, message1); + }); + it("should have messageImpressions in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "messageImpressions", + Router.state.messageImpressions + ); + }); + it("should return all unblocked messages that match the template, trigger if returnAll=true", async () => { + const message1 = { + provider: "whats_new", + id: "1", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message2 = { + provider: "whats_new", + id: "2", + template: "whatsnew_panel_message", + trigger: { id: "whatsNewPanelOpened" }, + groups: ["whats_new"], + }; + const message3 = { + provider: "whats_new", + id: "3", + template: "badge", + groups: ["whats_new"], + }; + ASRouterTargeting.findMatchingMessage.callsFake(() => [ + message2, + message1, + ]); + await Router.setState({ + messages: [message3, message2, message1], + providers: [{ id: "whats_new" }], + }); + const result = await Router.handleMessageRequest({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + + assert.deepEqual(result, [message2, message1]); + }); + it("should forward trigger param info", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options.trigger, "id", trigger.triggerId); + assert.propertyVal(options.trigger, "param", trigger.triggerParam); + assert.propertyVal(options.trigger, "context", trigger.triggerContext); + }); + it("should cache snippets messages", async () => { + const trigger = { + triggerId: "foo", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + }; + await Router.setState({ messages: [message2, message1] }); + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", true); + }); + it("should not cache badge messages", async () => { + const trigger = { + triggerId: "bar", + triggerParam: "bar", + triggerContext: "context", + }; + const message1 = { + id: "1", + provider: "snippets", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["badge"], + provider: "badge", + }; + await Router.setState({ messages: [message2, message1] }); + // Just return the first message provided as arg + + Router.handleMessageRequest(trigger); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + + const [options] = ASRouterTargeting.findMatchingMessage.firstCall.args; + assert.propertyVal(options, "shouldCache", false); + }); + it("should filter out messages without a trigger (or different) when a triggerId is defined", async () => { + const trigger = { triggerId: "foo" }; + const message1 = { + id: "1", + campaign: "foocampaign", + trigger: { id: "foo" }, + groups: ["snippets"], + provider: "snippets", + }; + const message2 = { + id: "2", + campaign: "foocampaign", + trigger: { id: "bar" }, + groups: ["snippets"], + provider: "snippets", + }; + const message3 = { + id: "3", + campaign: "bazcampaign", + groups: ["snippets"], + provider: "snippets", + }; + await Router.setState({ + messages: [message2, message1, message3], + groups: [{ id: "snippets", enabled: true }], + }); + // Just return the first message provided as arg + ASRouterTargeting.findMatchingMessage.callsFake(args => args.messages); + + const result = Router.handleMessageRequest(trigger); + + assert.lengthOf(result, 1); + assert.deepEqual(result[0], message1); + }); + }); + + describe("#uninit", () => { + it("should unregister the trigger listeners", () => { + for (const listener of ASRouterTriggerListeners.values()) { + sandbox.spy(listener, "uninit"); + } + + Router.uninit(); + + for (const listener of ASRouterTriggerListeners.values()) { + assert.calledOnce(listener.uninit); + } + }); + it("should set .dispatchCFRAction to null", () => { + Router.uninit(); + assert.isNull(Router.dispatchCFRAction); + assert.isNull(Router.clearChildMessages); + assert.isNull(Router.sendTelemetry); + }); + it("should save previousSessionEnd", () => { + Router.uninit(); + + assert.calledOnce(Router._storage.set); + assert.calledWithExactly( + Router._storage.set, + "previousSessionEnd", + sinon.match.number + ); + }); + it("should remove the observer for `intl:app-locales-changed`", () => { + sandbox.spy(global.Services.obs, "removeObserver"); + Router.uninit(); + + assert.calledWithExactly( + global.Services.obs.removeObserver, + Router._onLocaleChanged, + "intl:app-locales-changed" + ); + }); + it("should remove the pref observer for `USE_REMOTE_L10N_PREF`", async () => { + sandbox.spy(global.Services.prefs, "removeObserver"); + Router.uninit(); + + // Grab the last call as #uninit() also involves multiple calls of `Services.prefs.removeObserver`. + const call = global.Services.prefs.removeObserver.lastCall; + assert.calledWithExactly(call, USE_REMOTE_L10N_PREF, Router); + }); + }); + + describe("sendNewTabMessage", () => { + it("should construct an appropriate response message", async () => { + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let message = { + id: "foo", + provider: "snippets", + groups: ["snippets"], + }; + + await Router.setState({ + messages: [message], + providers: [{ id: "snippets" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => messages[0] + ); + + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, message); + }); + it("should send an empty object message if no messages are available", async () => { + await Router.setState({ messages: [] }); + let response = await Router.sendNewTabMessage({ + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, {}); + }); + + describe("#addPreviewEndpoint", () => { + it("should make a request to the provided endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.sendNewTabMessage({ + endpoint: { url }, + tabId: 0, + browser, + }); + + assert.calledWith(global.fetch, url); + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should send EnterSnippetPreviewMode when adding a preview endpoint", async () => { + const url = "https://snippets-admin.mozilla.org/foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.calledWithExactly( + browser.sendMessageToActor, + "EnterSnippetsPreviewMode", + {}, + "ASRouter" + ); + }); + it("should not add a url that is not from an allowed host", async () => { + const url = "https://mozilla.org"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + it("should reject bad urls", async () => { + const url = "foo"; + const browser = {}; + browser.sendMessageToActor = sandbox.stub(); + + await Router.addPreviewEndpoint(url, browser); + + assert.lengthOf( + Router.state.providers.filter(p => p.url === url), + 0 + ); + }); + }); + + it("should record telemetry for message request duration", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + sandbox.stub(Router, "handleMessageRequest"); + const tabId = 123; + await Router.sendNewTabMessage({ + tabId, + browser: {}, + }); + + // Called once for the messagesLoaded trigger and once for the above call. + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should return the preview message if that's available and remove it from Router.state", async () => { + const expectedObj = { + id: "foo", + groups: ["preview"], + provider: "preview", + }; + await Router.setState({ + messages: [expectedObj], + providers: [{ id: "preview" }], + }); + + ASRouterTargeting.findMatchingMessage.callsFake( + ({ messages }) => expectedObj + ); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + let response = await Router.sendNewTabMessage({ + endpoint: { url: "foo.com" }, + tabId: 0, + browser: {}, + }); + + assert.deepEqual(response.message, expectedObj); + + assert.isUndefined( + Router.state.messages.find(m => m.provider === "preview") + ); + }); + }); + + describe("#setMessageById", async () => { + it("should send an empty message if provided id did not resolve to a message", async () => { + let response = await Router.setMessageById({ id: -1 }, true, {}); + assert.deepEqual(response.message, {}); + }); + }); + + describe("#isUnblockedMessage", () => { + it("should block a message if the group is blocked", async () => { + const msg = { id: "msg1", groups: ["foo"], provider: "unit-test" }; + await Router.setState({ + groups: [{ id: "foo", enabled: false }], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ groups: [{ id: "foo", enabled: true }] }); + + assert.isTrue(Router.isUnblockedMessage(msg)); + }); + it("should block a message if at least one group is blocked", async () => { + const msg = { + id: "msg1", + groups: ["foo", "bar"], + provider: "unit-test", + }; + await Router.setState({ + groups: [ + { id: "foo", enabled: false }, + { id: "bar", enabled: false }, + ], + messages: [msg], + providers: [{ id: "unit-test" }], + }); + assert.isFalse(Router.isUnblockedMessage(msg)); + + await Router.setState({ + groups: [ + { id: "foo", enabled: true }, + { id: "bar", enabled: false }, + ], + }); + + assert.isFalse(Router.isUnblockedMessage(msg)); + }); + }); + + describe("#blockMessageById", () => { + it("should add the id to the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + }); + it("should add the campaign to the messageBlockList instead of id if .campaign is specified and not select messages of that campaign again", async () => { + await Router.setState({ + messages: [ + { id: "1", campaign: "foocampaign" }, + { id: "2", campaign: "foocampaign" }, + ], + }); + await Router.blockMessageById("1"); + + assert.isTrue(Router.state.messageBlockList.includes("foocampaign")); + assert.isEmpty(Router.state.messages.filter(Router.isUnblockedMessage)); + }); + it("should be able to add multiple items to the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[0].id)); + assert.isTrue(Router.state.messageBlockList.includes(FAKE_BUNDLE[1].id)); + }); + it("should save the messageBlockList", async () => { + await await Router.blockMessageById(FAKE_BUNDLE.map(b => b.id)); + assert.calledWithExactly(Router._storage.set, "messageBlockList", [ + FAKE_BUNDLE[0].id, + FAKE_BUNDLE[1].id, + ]); + }); + }); + + describe("#unblockMessageById", () => { + it("should remove the id from the messageBlockList", async () => { + await Router.blockMessageById("foo"); + assert.isTrue(Router.state.messageBlockList.includes("foo")); + await Router.unblockMessageById("foo"); + assert.isFalse(Router.state.messageBlockList.includes("foo")); + }); + it("should remove the campaign from the messageBlockList if it is defined", async () => { + await Router.setState({ messages: [{ id: "1", campaign: "foo" }] }); + await Router.blockMessageById("1"); + assert.isTrue( + Router.state.messageBlockList.includes("foo"), + "blocklist has campaign id" + ); + await Router.unblockMessageById("1"); + assert.isFalse( + Router.state.messageBlockList.includes("foo"), + "campaign id removed from blocklist" + ); + }); + it("should save the messageBlockList", async () => { + await Router.unblockMessageById("foo"); + assert.calledWithExactly(Router._storage.set, "messageBlockList", []); + }); + }); + + describe("#routeCFRMessage", () => { + it("should allow for echoing back message modifications", () => { + const message = { somekey: "some value" }; + const data = { content: message }; + const browser = {}; + let msg = Router.routeCFRMessage(data.content, browser, data, false); + assert.deepEqual(msg.message, message); + }); + it("should call CFRPageActions.forceRecommendation if the template is cfr_action and force is true", async () => { + sandbox.stub(CFRPageActions, "forceRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, null, true); + + assert.calledOnce(CFRPageActions.forceRecommendation); + }); + it("should call CFRPageActions.addRecommendation if the template is cfr_action and force is false", async () => { + sandbox.stub(CFRPageActions, "addRecommendation"); + const testMessage = { id: "foo", template: "cfr_doorhanger" }; + await Router.setState({ messages: [testMessage] }); + Router.routeCFRMessage(testMessage, {}, {}, false); + assert.calledOnce(CFRPageActions.addRecommendation); + }); + }); + + describe("#updateTargetingParameters", () => { + it("should return an object containing the whole state", async () => { + sandbox.stub(Router, "getTargetingParameters").resolves({}); + let msg = await Router.updateTargetingParameters(); + let expected = Object.assign({}, Router.state, { + providerPrefs: ASRouterPreferences.providers, + userPrefs: ASRouterPreferences.getAllUserPreferences(), + targetingParameters: {}, + errors: Router.errors, + }); + + assert.deepEqual(msg, expected); + }); + }); + + describe("#reachEvent", () => { + let experimentAPIStub; + let featureIds = ["cfr", "moments-page", "infobar", "spotlight"]; + beforeEach(() => { + let getExperimentMetaDataStub = sandbox.stub(); + let getAllBranchesStub = sandbox.stub(); + featureIds.forEach(feature => { + global.NimbusFeatures[feature].getAllVariables.returns({ + id: `message-${feature}`, + }); + getExperimentMetaDataStub.withArgs({ featureId: feature }).returns({ + slug: `slug-${feature}`, + branch: { + slug: `branch-${feature}`, + }, + }); + getAllBranchesStub.withArgs(`slug-${feature}`).resolves([ + { + slug: `other-branch-${feature}`, + [feature]: { value: { trigger: "unit-test" } }, + }, + ]); + }); + experimentAPIStub = { + getExperimentMetaData: getExperimentMetaDataStub, + getAllBranches: getAllBranchesStub, + }; + globals.set("ExperimentAPI", experimentAPIStub); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should tag `forReachEvent` for all the expected message types", async () => { + // This should match the `providers.messaging-experiments` + let response = await MessageLoaderUtils.loadMessagesForProvider({ + type: "remote-experiments", + featureIds, + }); + + // 1 message for reach 1 for expose + assert.property(response, "messages"); + assert.lengthOf(response.messages, featureIds.length * 2); + assert.lengthOf( + response.messages.filter(m => m.forReachEvent), + featureIds.length + ); + }); + }); + + describe("#sendTriggerMessage", () => { + it("should pass the trigger to ASRouterTargeting when sending trigger message", async () => { + await Router.setState({ + messages: [ + { + id: "foo1", + provider: "onboarding", + template: "onboarding", + trigger: { id: "firstRun" }, + content: { title: "Foo1", body: "Foo123-1" }, + groups: ["onboarding"], + }, + ], + providers: [{ id: "onboarding" }], + }); + + Router.loadMessagesFromAllProviders.resetHistory(); + Router.loadMessagesFromAllProviders.onFirstCall().resolves(); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "firstRun", + }); + + assert.calledOnce(ASRouterTargeting.findMatchingMessage); + assert.deepEqual( + ASRouterTargeting.findMatchingMessage.firstCall.args[0].trigger, + { + id: "firstRun", + param: undefined, + context: undefined, + } + ); + }); + it("should record telemetry information", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + const tabId = 123; + + await Router.sendTriggerMessage({ + tabId, + browser: {}, + id: "firstRun", + }); + + assert.calledTwice(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + assert.calledTwice(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { tabId } + ); + }); + it("should have previousSessionEnd in the message context", () => { + assert.propertyVal( + Router._getMessagesContext(), + "previousSessionEnd", + 100 + ); + }); + it("should record the Reach event if found any", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_template", + trigger: { id: "bar" }, + content: { title: "Foo2", body: "Foo123-2" }, + provider: "onboarding", + }, + { + id: "foo3", + forReachEvent: { sent: false, group: "cfr" }, + experimentSlug: "exp02", + branchSlug: "branch02", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledTwice(Services.telemetry.recordEvent); + }); + it("should not record the Reach event if it's already sent", async () => { + let messages = [ + { + id: "foo1", + forReachEvent: { sent: true, group: "cfr" }, + experimentSlug: "exp01", + branchSlug: "branch01", + template: "simple_template", + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + sandbox.spy(Services.telemetry, "recordEvent"); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + assert.notCalled(Services.telemetry.recordEvent); + }); + it("should record the Exposure event for each valid feature", async () => { + ["cfr_doorhanger", "update_action", "infobar", "spotlight"].forEach( + async template => { + let featureMap = { + cfr_doorhanger: "cfr", + spotlight: "spotlight", + infobar: "infobar", + update_action: "moments-page", + }; + assert.notCalled( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + + let messages = [ + { + id: "foo1", + template, + trigger: { id: "foo" }, + content: { title: "Foo1", body: "Foo123-1" }, + }, + ]; + sandbox.stub(Router, "handleMessageRequest").resolves(messages); + + await Router.sendTriggerMessage({ + tabId: 0, + browser: {}, + id: "foo", + }); + + assert.calledOnce( + global.NimbusFeatures[featureMap[template]].recordExposureEvent + ); + } + ); + }); + }); + + describe("forceAttribution", () => { + let setReferrerUrl; + beforeEach(() => { + setReferrerUrl = sinon.spy(); + global.Cc["@mozilla.org/mac-attribution;1"] = { + getService: () => ({ setReferrerUrl }), + }; + + sandbox.stub(global.Services.env, "set"); + }); + it("should double encode on windows", async () => { + sandbox.stub(fakeAttributionCode, "writeAttributionFile"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.notCalled(setReferrerUrl); + assert.calledWithMatch( + fakeAttributionCode.writeAttributionFile, + "foo%3DFOO!%26bar%3DBAR%253F" + ); + }); + it("should set referrer on mac", async () => { + sandbox.stub(global.AppConstants, "platform").value("macosx"); + + Router.forceAttribution({ foo: "FOO!", eh: "NOPE", bar: "BAR?" }); + + assert.calledOnce(setReferrerUrl); + assert.calledWithMatch(setReferrerUrl, "", "?foo=FOO!&bar=BAR%3F"); + }); + }); + + describe("#forceWNPanel", () => { + let browser = { + ownerGlobal: { + document: new Document(), + PanelUI: { + showSubView: sinon.stub(), + panel: { + setAttribute: sinon.stub(), + }, + }, + }, + }; + let fakePanel = { + setAttribute: sinon.stub(), + }; + sinon + .stub(browser.ownerGlobal.document, "getElementById") + .returns(fakePanel); + + it("should call enableToolbarButton", async () => { + await Router.forceWNPanel(browser); + assert.calledOnce(FakeToolbarPanelHub.enableToolbarButton); + assert.calledOnce(browser.ownerGlobal.PanelUI.showSubView); + assert.calledWith(fakePanel.setAttribute, "noautohide", true); + }); + }); + + describe("_triggerHandler", () => { + it("should call #sendTriggerMessage with the correct trigger", () => { + const getter = sandbox.stub(); + getter.returns(false); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.calledOnce(Router.sendTriggerMessage); + assert.calledWith( + Router.sendTriggerMessage, + sandbox.match({ + id: "FAKE_TRIGGER", + param: "some fake param", + }) + ); + }); + }); + + describe("_triggerHandler_kiosk", () => { + it("should not call #sendTriggerMessage", () => { + const getter = sandbox.stub(); + getter.returns(true); + sandbox.stub(global.BrowserHandler, "kiosk").get(getter); + sinon.spy(Router, "sendTriggerMessage"); + const browser = {}; + const trigger = { id: "FAKE_TRIGGER", param: "some fake param" }; + Router._triggerHandler(browser, trigger); + assert.notCalled(Router.sendTriggerMessage); + }); + }); + + describe("valid preview endpoint", () => { + it("should report an error if url protocol is not https", () => { + sandbox.stub(console, "error"); + + assert.equal(false, Router._validPreviewEndpoint("http://foo.com")); + assert.calledTwice(console.error); + }); + }); + + describe("impressions", () => { + describe("#addImpression for groups", () => { + it("should save an impression in each group-with-frequency in a message", async () => { + const fooMessageImpressions = [0]; + const aGroupImpressions = [0, 1, 2]; + const bGroupImpressions = [3, 4, 5]; + const cGroupImpressions = [6, 7, 8]; + + const message = { + id: "foo", + provider: "bar", + groups: ["a", "b", "c"], + }; + const groups = [ + { id: "a", frequency: { lifetime: 3 } }, + { id: "b", frequency: { lifetime: 4 } }, + { id: "c", frequency: { lifetime: 5 } }, + ]; + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.a = aGroupImpressions; + gImpressions.b = bGroupImpressions; + gImpressions.c = cGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.addImpression(message); + + assert.deepEqual( + Router.state.groupImpressions.a, + [0, 1, 2, 0], + "a impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.b, + [3, 4, 5, 0], + "b impressions" + ); + assert.deepEqual( + Router.state.groupImpressions.c, + [6, 7, 8, 0], + "c impressions" + ); + }); + }); + + describe("#isBelowFrequencyCaps", () => { + it("should call #_isBelowItemFrequencyCap for the message and for the provider with the correct impressions and arguments", async () => { + sinon.spy(Router, "_isBelowItemFrequencyCap"); + + const MAX_MESSAGE_LIFETIME_CAP = 100; // Defined in ASRouter + const fooMessageImpressions = [0, 1]; + const barGroupImpressions = [0, 1, 2]; + + const message = { + id: "foo", + provider: "bar", + groups: ["bar"], + frequency: { lifetime: 3 }, + }; + const groups = [{ id: "bar", frequency: { lifetime: 5 } }]; + + await Router.setState(state => { + // Add provider + const providers = [...state.providers]; + // Add fooMessageImpressions + // eslint-disable-next-line no-shadow + const messageImpressions = Object.assign( + {}, + state.messageImpressions + ); + let gImpressions = {}; + gImpressions.bar = barGroupImpressions; + messageImpressions.foo = fooMessageImpressions; + return { + providers, + messageImpressions, + groups, + groupImpressions: gImpressions, + }; + }); + + await Router.isBelowFrequencyCaps(message); + + assert.calledTwice(Router._isBelowItemFrequencyCap); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + message, + fooMessageImpressions, + MAX_MESSAGE_LIFETIME_CAP + ); + assert.calledWithExactly( + Router._isBelowItemFrequencyCap, + groups[0], + barGroupImpressions + ); + }); + }); + + describe("#_isBelowItemFrequencyCap", () => { + it("should return false if the # of impressions exceeds the maxLifetimeCap", () => { + const item = { id: "foo", frequency: { lifetime: 5 } }; + const impressions = [0, 1]; + const maxLifetimeCap = 1; + const result = Router._isBelowItemFrequencyCap( + item, + impressions, + maxLifetimeCap + ); + assert.isFalse(result); + }); + + describe("lifetime frequency caps", () => { + it("should return true if .frequency is not defined on the item", () => { + const item = { id: "foo" }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if there are no impressions", () => { + const item = { + id: "foo", + frequency: { + lifetime: 10, + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + }, + }; + const impressions = []; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return true if the # of impressions is less than .frequency.lifetime of the item", () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if the # of impressions is equal to .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if the # of impressions is greater than .frequency.lifetime of the item", async () => { + const item = { id: "foo", frequency: { lifetime: 3 } }; + const impressions = [0, 1, 2, 3]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + + describe("custom frequency caps", () => { + it("should return true if impressions in the time period < the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "foo", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if impressions in the time period > the cap and total impressions < the lifetime cap", () => { + clock.tick(200); + const item = { + id: "msg1", + frequency: { custom: [{ period: 100, cap: 2 }], lifetime: 3 }, + }; + const impressions = [0, 160, 161]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return false if impressions in one of the time periods > the cap and total impressions < the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 200); + const itemTrue = { + id: "msg2", + frequency: { custom: [{ period: 100, cap: 2 }] }, + }; + const itemFalse = { + id: "msg1", + frequency: { + custom: [ + { period: 100, cap: 2 }, + { period: ONE_DAY_IN_MS, cap: 3 }, + ], + }, + }; + const impressions = [ + 0, + ONE_DAY_IN_MS + 160, + ONE_DAY_IN_MS - 100, + ONE_DAY_IN_MS - 200, + ]; + assert.isTrue(Router._isBelowItemFrequencyCap(itemTrue, impressions)); + assert.isFalse( + Router._isBelowItemFrequencyCap(itemFalse, impressions) + ); + }); + it("should return false if impressions in the time period < the cap and total impressions > the lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { + custom: [{ period: ONE_DAY_IN_MS, cap: 2 }], + lifetime: 3, + }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + it("should return true if daily impressions < the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [0, 1, 2, 3, ONE_DAY_IN_MS + 1]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isTrue(result); + }); + it("should return false if daily impressions > the daily cap and there is no lifetime cap", () => { + clock.tick(ONE_DAY_IN_MS + 10); + const item = { + id: "msg1", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 2 }] }, + }; + const impressions = [ + 0, + 1, + 2, + 3, + ONE_DAY_IN_MS + 1, + ONE_DAY_IN_MS + 2, + ONE_DAY_IN_MS + 3, + ]; + const result = Router._isBelowItemFrequencyCap(item, impressions); + assert.isFalse(result); + }); + }); + }); + + describe("#getLongestPeriod", () => { + it("should return the period if there is only one definition", () => { + const message = { + id: "foo", + frequency: { custom: [{ period: 200, cap: 2 }] }, + }; + assert.equal(Router.getLongestPeriod(message), 200); + }); + it("should return the longest period if there are more than one definitions", () => { + const message = { + id: "foo", + frequency: { + custom: [ + { period: 1000, cap: 3 }, + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }; + assert.equal(Router.getLongestPeriod(message), ONE_DAY_IN_MS); + }); + it("should return null if there are is no .frequency", () => { + const message = { id: "foo" }; + assert.isNull(Router.getLongestPeriod(message)); + }); + it("should return null if there are is no .frequency.custom", () => { + const message = { id: "foo", frequency: { lifetime: 10 } }; + assert.isNull(Router.getLongestPeriod(message)); + }); + }); + + describe("cleanup on init", () => { + it("should clear messageImpressions for messages which do not exist in state.messages", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + messageImpressions = { foo: [0], bar: [0, 1] }; + // Impressions for "bar" should be removed since that id does not exist in messages + const result = { foo: [0] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { custom: [{ period: ONE_DAY_IN_MS, cap: 5 }] }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions older than the longest period if no lifetime impression cap is included", async () => { + const CURRENT_TIME = ONE_DAY_IN_MS * 2; + clock.tick(CURRENT_TIME); + const messages = [ + { + id: "foo", + frequency: { + custom: [ + { period: ONE_DAY_IN_MS, cap: 5 }, + { period: 100, cap: 2 }, + ], + }, + }, + ]; + messageImpressions = { foo: [0, 1, CURRENT_TIME - 10] }; + // Only 0 and 1 are more than 24 hours before CURRENT_TIME + const result = { foo: [CURRENT_TIME - 10] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should clear messageImpressions if they are not properly formatted", async () => { + const messages = [{ id: "foo", frequency: { lifetime: 10 } }]; + // this is impromperly formatted since messageImpressions are supposed to be an array + messageImpressions = { foo: 0 }; + const result = {}; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.calledWith(Router._storage.set, "messageImpressions", result); + assert.deepEqual(Router.state.messageImpressions, result); + }); + it("should not clear messageImpressions for messages which do exist in state.messages", async () => { + const messages = [ + { id: "foo", frequency: { lifetime: 10 } }, + { id: "bar", frequency: { lifetime: 10 } }, + ]; + messageImpressions = { foo: [0], bar: [] }; + + await createRouterAndInit([ + { id: "onboarding", type: "local", messages, enabled: true }, + ]); + assert.notCalled(Router._storage.set); + assert.deepEqual(Router.state.messageImpressions, messageImpressions); + }); + }); + }); + + describe("#_onLocaleChanged", () => { + it("should call _maybeUpdateL10nAttachment in the handler", async () => { + sandbox.spy(Router, "_maybeUpdateL10nAttachment"); + await Router._onLocaleChanged(); + + assert.calledOnce(Router._maybeUpdateL10nAttachment); + }); + }); + + describe("#_maybeUpdateL10nAttachment", () => { + it("should update the l10n attachment if the locale was changed", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + }; + await createRouterAndInit([provider]); + sandbox.spy(Router, "setState"); + Router.loadMessagesFromAllProviders.resetHistory(); + + await Router._maybeUpdateL10nAttachment(); + + assert.calledWith(Router.setState, { + localeInUse: "fr", + providers: [ + { + id: "cfr", + enabled: true, + type: "remote-settings", + collection: "cfr", + lastUpdated: undefined, + errors: [], + }, + ], + }); + assert.calledOnce(Router.loadMessagesFromAllProviders); + }); + it("should not update the l10n attachment if the provider doesn't need l10n attachment", async () => { + const getter = sandbox.stub(); + getter.onFirstCall().returns("en-US"); + getter.onSecondCall().returns("fr"); + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(getter); + const provider = { + id: "localProvider", + enabled: true, + type: "local", + }; + await createRouterAndInit([provider]); + Router.loadMessagesFromAllProviders.resetHistory(); + sandbox.spy(Router, "setState"); + + await Router._maybeUpdateL10nAttachment(); + + assert.notCalled(Router.setState); + assert.notCalled(Router.loadMessagesFromAllProviders); + }); + }); + describe("#observe", () => { + it("should reload l10n for CFRPageActions when the `USE_REMOTE_L10N_PREF` pref is changed", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", USE_REMOTE_L10N_PREF); + + assert.calledOnce(CFRPageActions.reloadL10n); + }); + it("should not react to other pref changes", () => { + sandbox.spy(CFRPageActions, "reloadL10n"); + + Router.observe("", "", "foo"); + + assert.notCalled(CFRPageActions.reloadL10n); + }); + }); + describe("#loadAllMessageGroups", () => { + it("should disable the group if the pref is false", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should enable the group if at least one pref is true", async () => { + sandbox + .stub(ASRouterPreferences, "getUserPreference") + .withArgs("cfrAddons") + .returns(false) + .withArgs("cfrFeatures") + .returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: true, + type: "remote", + userPreferences: ["cfrAddons", "cfrFeatures"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + }); + it("should be keep the group disabled if disabled is true", async () => { + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(true); + sandbox.stub(MessageLoaderUtils, "_getRemoteSettingsMessages").resolves([ + { + id: "provider-group", + enabled: false, + type: "remote", + userPreferences: ["cfrAddons"], + }, + ]); + await Router.setState({ + providers: [ + { + id: "message-groups", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "provider-group"); + + assert.ok(group); + assert.propertyVal(group, "enabled", false); + }); + it("should keep local groups unchanged if provider doesn't require an update", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(MessageLoaderUtils, "_loadDataForProvider"); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + assert.propertyVal(group, "enabled", true); + // Because it should not have updated + assert.notCalled(MessageLoaderUtils._loadDataForProvider); + }); + it("should update local groups on pref change (no RS update)", async () => { + sandbox.stub(MessageLoaderUtils, "shouldProviderUpdate").returns(false); + sandbox.stub(ASRouterPreferences, "getUserPreference").returns(false); + await Router.setState({ + groups: [ + { + id: "cfr", + enabled: true, + collection: "collection", + type: "remote-settings", + userPreferences: ["cfrAddons"], + }, + ], + }); + + await Router.loadAllMessageGroups(); + + const group = Router.state.groups.find(g => g.id === "cfr"); + + assert.ok(group); + // Pref changed, updated the group state + assert.propertyVal(group, "enabled", false); + }); + }); + describe("unblockAll", () => { + it("Clears the message block list and returns the state value", async () => { + await Router.setState({ messageBlockList: ["one", "two", "three"] }); + assert.equal(Router.state.messageBlockList.length, 3); + const state = await Router.unblockAll(); + assert.equal(Router.state.messageBlockList.length, 0); + assert.equal(state.messageBlockList.length, 0); + }); + }); + describe("#loadMessagesForProvider", () => { + it("should fetch messages from the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["spotlight"], + }; + + await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.calledOnce(global.NimbusFeatures.spotlight.getAllVariables); + assert.calledOnce(global.ExperimentAPI.getExperimentMetaData); + assert.calledWithExactly(global.ExperimentAPI.getExperimentMetaData, { + featureId: "spotlight", + }); + }); + it("should handle the case of no experiments in the ExperimentAPI", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + + global.ExperimentAPI.getExperiment.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should normally load ExperimentAPI messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["infobar"], + }; + const enrollment = { + branch: { + slug: "branch01", + infobar: { + featureId: "infobar", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.infobar.getAllVariables.returns( + enrollment.branch.infobar.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.returns([ + enrollment.branch, + { + slug: "control", + infobar: { + featureId: "infobar", + value: null, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 1); + }); + it("should skip disabled features and not load the messages", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + + global.NimbusFeatures.cfr.getAllVariables.returns(null); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.lengthOf(result.messages, 0); + }); + it("should fetch branches with trigger", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: { id: "id01", trigger: { id: "openURL" } }, + }, + }, + }; + + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 2); + assert.equal(result.messages[0].id, "id01"); + assert.equal(result.messages[1].id, "id02"); + assert.equal(result.messages[1].experimentSlug, "exp01"); + assert.equal(result.messages[1].branchSlug, "branch02"); + assert.deepEqual(result.messages[1].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + it("should fetch branches with trigger even if enrolled branch is disabled", async () => { + const args = { + type: "remote-experiments", + featureIds: ["cfr"], + }; + const enrollment = { + slug: "exp01", + branch: { + slug: "branch01", + cfr: { + featureId: "cfr", + value: {}, + }, + }, + }; + + // Nedds to match the `featureIds` value to return an enrollment + // for that feature + global.NimbusFeatures.cfr.getAllVariables.returns( + enrollment.branch.cfr.value + ); + global.ExperimentAPI.getExperimentMetaData.returns({ + slug: enrollment.slug, + active: true, + branch: { slug: enrollment.branch.slug }, + }); + global.ExperimentAPI.getAllBranches.resolves([ + enrollment.branch, + { + slug: "branch02", + cfr: { + featureId: "cfr", + value: { id: "id02", trigger: { id: "openURL" } }, + }, + }, + { + // This branch should not be loaded as it doesn't have the trigger + slug: "branch03", + cfr: { + featureId: "cfr", + value: { id: "id03" }, + }, + }, + ]); + + const result = await MessageLoaderUtils.loadMessagesForProvider(args); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, "id02"); + assert.equal(result.messages[0].experimentSlug, "exp01"); + assert.equal(result.messages[0].branchSlug, "branch02"); + assert.deepEqual(result.messages[0].forReachEvent, { + sent: false, + group: "cfr", + }); + }); + }); + describe("#_remoteSettingsLoader", () => { + let provider; + let spy; + beforeEach(() => { + provider = { + id: "cfr", + collection: "cfr", + }; + sandbox + .stub(MessageLoaderUtils, "_getRemoteSettingsMessages") + .resolves([{ id: "message_1" }]); + spy = sandbox.spy(); + global.Downloader.prototype.downloadToDisk = spy; + }); + it("should be called with the expected dir path", async () => { + const dlSpy = sandbox.spy(global, "Downloader"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledWith( + dlSpy, + "main", + "ms-language-packs", + "browser", + "newtab" + ); + }); + it("should allow fetch for known locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.calledOnce(spy); + }); + it("should fallback to 'en-US' for locale 'und' ", async () => { + sandbox.stub(global.Services.locale, "appLocaleAsBCP47").get(() => "und"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("en-US")); + assert.calledOnce(spy); + }); + it("should fallback to 'ja-JP-mac' for locale 'ja-JP-macos'", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "ja-JP-macos"); + const getRecordSpy = sandbox.spy( + global.KintoHttpClient.prototype, + "getRecord" + ); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.ok(getRecordSpy.args[0][0].includes("ja-JP-mac")); + assert.calledOnce(spy); + }); + it("should not allow fetch for unsupported locales", async () => { + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "unkown"); + + await MessageLoaderUtils._remoteSettingsLoader(provider, {}); + + assert.notCalled(spy); + }); + }); + describe("#resetMessageState", () => { + it("should reset all message impressions", async () => { + await Router.setState({ + messages: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + messageImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test messages + let impressions = Object.values(Router.state.messageImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both messages have impressions + + Router.resetMessageState(); + impressions = Object.values(Router.state.messageImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both messages now have zero impressions + assert.calledWithExactly(Router._storage.set, "messageImpressions", { + 1: [], + 2: [], + }); + }); + }); + describe("#resetGroupsState", () => { + it("should reset all group impressions", async () => { + await Router.setState({ + groups: [{ id: "1" }, { id: "2" }], + }); + await Router.setState({ + groupImpressions: { 1: [0, 1, 2], 2: [0, 1, 2] }, + }); // Add impressions for test groups + let impressions = Object.values(Router.state.groupImpressions); + assert.equal(impressions.filter(i => i.length).length, 2); // Both groups have impressions + + Router.resetGroupsState(); + impressions = Object.values(Router.state.groupImpressions); + + assert.isEmpty(impressions.filter(i => i.length)); // Both groups now have zero impressions + assert.calledWithExactly(Router._storage.set, "groupImpressions", { + 1: [], + 2: [], + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js new file mode 100644 index 0000000000..346f0e02f3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterChild.test.js @@ -0,0 +1,74 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterChild } from "actors/ASRouterChild.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterChild", () => { + let asRouterChild = null; + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + Cu: { + cloneInto: sandbox.stub().returns(Promise.resolve()), + }, + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + asRouterChild = new ASRouterChild(); + asRouterChild.telemetry = { + sendTelemetry: sandbox.stub(), + }; + sandbox.stub(asRouterChild, "sendAsyncMessage"); + sandbox.stub(asRouterChild, "sendQuery").returns(Promise.resolve()); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + asRouterChild = null; + }); + describe("asRouterMessage", () => { + describe("uses sendAsyncMessage for types that don't need an async response", () => { + [ + msg.DISABLE_PROVIDER, + msg.ENABLE_PROVIDER, + msg.EXPIRE_QUERY_CACHE, + msg.FORCE_WHATSNEW_PANEL, + msg.IMPRESSION, + msg.RESET_PROVIDER_PREF, + msg.SET_PROVIDER_USER_PREF, + msg.USER_ACTION, + ].forEach(type => { + it(`type ${type}`, () => { + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendAsyncMessage); + sandbox.assert.calledWith(asRouterChild.sendAsyncMessage, type, { + something: 1, + }); + }); + }); + }); + describe("use sends messages that need a response using sendQuery", () => { + it("NEWTAB_MESSAGE_REQUEST", () => { + const type = msg.NEWTAB_MESSAGE_REQUEST; + asRouterChild.asRouterMessage({ + type, + data: { + something: 1, + }, + }); + sandbox.assert.calledOnce(asRouterChild.sendQuery); + sandbox.assert.calledWith(asRouterChild.sendQuery, type, { + something: 1, + }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js new file mode 100644 index 0000000000..938c85d7de --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterNewTabHook.test.js @@ -0,0 +1,153 @@ +/*eslint max-nested-callbacks: ["error", 10]*/ +import { ASRouterNewTabHook } from "lib/ASRouterNewTabHook.sys.mjs"; + +describe("ASRouterNewTabHook", () => { + let sandbox = null; + let initParams = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + initParams = { + router: { + init: sandbox.stub().callsFake(() => { + // Fake the initialization + initParams.router.initialized = true; + }), + uninit: sandbox.stub(), + }, + messageHandler: { + handleCFRAction: {}, + handleTelemetry: {}, + }, + createStorage: () => Promise.resolve({}), + }; + }); + afterEach(() => { + sandbox.restore(); + }); + describe("ASRouterNewTabHook", () => { + describe("getInstance", () => { + it("awaits createInstance and router init before returning instance", async () => { + const getInstanceCall = sandbox.spy(); + const waitForInstance = + ASRouterNewTabHook.getInstance().then(getInstanceCall); + await ASRouterNewTabHook.createInstance(initParams); + await waitForInstance; + assert.callOrder(initParams.router.init, getInstanceCall); + }); + }); + describe("createInstance", () => { + it("calls router init", async () => { + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + it("only calls router init once", async () => { + initParams.router.init.callsFake(() => { + initParams.router.initialized = true; + }); + await ASRouterNewTabHook.createInstance(initParams); + await ASRouterNewTabHook.createInstance(initParams); + assert.calledOnce(initParams.router.init); + }); + }); + describe("destroy", () => { + it("disconnects new tab, uninits ASRouter, and destroys instance", async () => { + await ASRouterNewTabHook.createInstance(initParams); + const instance = await ASRouterNewTabHook.getInstance(); + const destroy = instance.destroy.bind(instance); + sandbox.stub(instance, "destroy").callsFake(destroy); + ASRouterNewTabHook.destroy(); + assert.calledOnce(initParams.router.uninit); + assert.calledOnce(instance.destroy); + assert.isNotNull(instance); + assert.isNull(instance._newTabMessageHandler); + }); + }); + describe("instance", () => { + let routerParams = null; + let messageHandler = null; + let instance = null; + beforeEach(async () => { + messageHandler = { + clearChildMessages: sandbox.stub().resolves(), + clearChildProviders: sandbox.stub().resolves(), + updateAdminState: sandbox.stub().resolves(), + }; + initParams.router.init.callsFake(params => { + routerParams = params; + }); + await ASRouterNewTabHook.createInstance(initParams); + instance = await ASRouterNewTabHook.getInstance(); + }); + describe("connect", () => { + it("before connection messageHandler methods are not called", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("after connect updateAdminState and clearChildMessages calls are forwarded to handler", async () => { + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + assert.called(messageHandler.clearChildMessages); + assert.called(messageHandler.clearChildProviders); + assert.called(messageHandler.updateAdminState); + }); + it("calls from before connection are dropped", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + }); + }); + describe("disconnect", () => { + it("calls after disconnect are dropped", async () => { + instance.connect(messageHandler); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["snippets"]); + routerParams.updateAdminState({ messages: {} }); + assert.notCalled(messageHandler.clearChildMessages); + assert.notCalled(messageHandler.clearChildProviders); + assert.notCalled(messageHandler.updateAdminState); + }); + it("only calls from when there is a connection are forwarded", async () => { + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + instance.connect(messageHandler); + routerParams.clearChildMessages([200]); + routerParams.clearChildProviders(["bar"]); + routerParams.updateAdminState({ + messages: { + data: "accept", + }, + }); + instance.disconnect(); + routerParams.clearChildMessages([1]); + routerParams.clearChildProviders(["foo"]); + routerParams.updateAdminState({ messages: {} }); + assert.calledOnce(messageHandler.clearChildMessages); + assert.calledOnce(messageHandler.clearChildProviders); + assert.calledOnce(messageHandler.updateAdminState); + assert.calledWith(messageHandler.clearChildMessages, [200]); + assert.calledWith(messageHandler.clearChildProviders, ["bar"]); + assert.calledWith(messageHandler.updateAdminState, { + messages: { + data: "accept", + }, + }); + }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js new file mode 100644 index 0000000000..1b494bbe0e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterParent.test.js @@ -0,0 +1,106 @@ +import { ASRouterParent } from "actors/ASRouterParent.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; + +describe("ASRouterParent", () => { + let asRouterParent = null; + let sandbox = null; + let handleMessage = null; + let tabs = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + handleMessage = sandbox.stub().resolves("handle-message-result"); + ASRouterParent.nextTabId = 1; + const methods = { + destroy: sandbox.stub(), + size: 1, + messageAll: sandbox.stub().resolves(), + registerActor: sandbox.stub(), + unregisterActor: sandbox.stub(), + loadingMessageHandler: Promise.resolve({ + handleMessage, + }), + }; + tabs = { + methods, + factory: sandbox.stub().returns(methods), + }; + asRouterParent = new ASRouterParent({ tabsFactory: tabs.factory }); + ASRouterParent.tabs = tabs.methods; + asRouterParent.browsingContext = { + embedderElement: { + getAttribute: () => true, + }, + }; + asRouterParent.tabId = ASRouterParent.nextTabId; + }); + afterEach(() => { + sandbox.restore(); + asRouterParent = null; + }); + describe("actorCreated", () => { + it("after ASRouterTabs is instanced", () => { + asRouterParent.actorCreated(); + assert.equal(asRouterParent.tabId, 2); + assert.notCalled(tabs.factory); + assert.calledOnce(tabs.methods.registerActor); + }); + it("before ASRouterTabs is instanced", () => { + ASRouterParent.tabs = null; + ASRouterParent.nextTabId = 0; + asRouterParent.actorCreated(); + assert.calledOnce(tabs.factory); + assert.isNotNull(ASRouterParent.tabs); + assert.equal(asRouterParent.tabId, 1); + }); + }); + describe("didDestroy", () => { + it("one still remains", () => { + ASRouterParent.tabs.size = 1; + asRouterParent.didDestroy(); + assert.isNotNull(ASRouterParent.tabs); + assert.calledOnce(ASRouterParent.tabs.unregisterActor); + assert.notCalled(ASRouterParent.tabs.destroy); + }); + it("none remain", () => { + ASRouterParent.tabs.size = 0; + const tabsCopy = ASRouterParent.tabs; + asRouterParent.didDestroy(); + assert.isNull(ASRouterParent.tabs); + assert.calledOnce(tabsCopy.unregisterActor); + assert.calledOnce(tabsCopy.destroy); + }); + }); + describe("receiveMessage", async () => { + it("passes call to parentProcessMessageHandler and returns the result from handler", async () => { + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.calledOnce(handleMessage); + assert.equal(result, "handle-message-result"); + }); + it("it messages all actors on BLOCK_MESSAGE_BY_ID messages", async () => { + const MESSAGE_ID = 1; + const result = await asRouterParent.receiveMessage({ + name: msg.BLOCK_MESSAGE_BY_ID, + data: { id: MESSAGE_ID, campaign: "message-campaign" }, + }); + assert.calledOnce(handleMessage); + // Check that we correctly pass the tabId + assert.calledWithExactly( + handleMessage, + sinon.match.any, + sinon.match.any, + { id: sinon.match.number, browser: sinon.match.any } + ); + assert.calledWithExactly( + ASRouterParent.tabs.messageAll, + "ClearMessages", + // When blocking an id the entire campaign is blocked + // and all other snippets become invalid + ["message-campaign"] + ); + assert.equal(result, "handle-message-result"); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js new file mode 100644 index 0000000000..1f35ab875e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterParentProcessMessageHandler.test.js @@ -0,0 +1,428 @@ +import { ASRouterParentProcessMessageHandler } from "lib/ASRouterParentProcessMessageHandler.jsm"; +import { _ASRouter } from "lib/ASRouter.jsm"; +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; + +describe("ASRouterParentProcessMessageHandler", () => { + let handler = null; + let sandbox = null; + let config = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + const returnValue = { value: 1 }; + const router = new _ASRouter(); + [ + "addImpression", + "addPreviewEndpoint", + "evaluateExpression", + "forceAttribution", + "forceWNPanel", + "closeWNPanel", + "forcePBWindow", + "resetGroupsState", + ].forEach(method => sandbox.stub(router, `${method}`).resolves()); + [ + "blockMessageById", + "loadMessagesFromAllProviders", + "sendNewTabMessage", + "sendTriggerMessage", + "routeCFRMessage", + "setMessageById", + "updateTargetingParameters", + "unblockMessageById", + "unblockAll", + ].forEach(method => + sandbox.stub(router, `${method}`).resolves(returnValue) + ); + router._storage = { + set: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + }; + sandbox.stub(router, "setState").callsFake(callback => { + if (typeof callback === "function") { + callback({ + messageBlockList: [ + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + { + id: 4, + }, + ], + }); + } + return Promise.resolve(returnValue); + }); + const preferences = { + enableOrDisableProvider: sandbox.stub(), + resetProviderPref: sandbox.stub(), + setUserPreference: sandbox.stub(), + }; + const specialMessageActions = { + handleAction: sandbox.stub(), + }; + const queryCache = { + expireAll: sandbox.stub(), + }; + const sendTelemetry = sandbox.stub(); + config = { + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }; + handler = new ASRouterParentProcessMessageHandler(config); + }); + afterEach(() => { + sandbox.restore(); + handler = null; + config = null; + }); + describe("constructor", () => { + it("does not throw", () => { + assert.isNotNull(handler); + assert.isNotNull(config); + }); + }); + describe("handleCFRAction", () => { + it("non-telemetry type isn't sent to telemetry", () => { + handler.handleCFRAction({ + type: msg.BLOCK_MESSAGE_BY_ID, + data: { id: 1 }, + }); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.router.blockMessageById); + }); + it("passes browser to handleMessage", async () => { + await handler.handleCFRAction( + { + type: msg.USER_ACTION, + data: { id: 1 }, + }, + { ownerGlobal: {} } + ); + assert.notCalled(config.sendTelemetry); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { id: 1 }, + { ownerGlobal: {} } + ); + }); + [ + msg.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ].forEach(type => { + it(`telemetry type "${type}" is sent to telemetry`, () => { + handler.handleCFRAction({ + type, + data: { id: 1 }, + }); + assert.calledOnce(config.sendTelemetry); + assert.notCalled(config.router.blockMessageById); + }); + }); + }); + describe("#handleMessage", () => { + it("#default: should throw for unknown msg types", () => { + handler.handleMessage("err").then( + () => assert.fail("It should not succeed"), + () => assert.ok(true) + ); + }); + describe("#AS_ROUTER_TELEMETRY_USER_EVENT", () => { + it("should route AS_ROUTER_TELEMETRY_USER_EVENT to handleTelemetry", async () => { + const data = { data: "foo" }; + await handler.handleMessage(msg.AS_ROUTER_TELEMETRY_USER_EVENT, data); + + assert.calledOnce(handler.handleTelemetry); + assert.calledWithExactly(handler.handleTelemetry, { + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + }); + }); + describe("BLOCK_MESSAGE_BY_ID action", () => { + it("with preventDismiss returns false", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + preventDismiss: true, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isFalse(result); + }); + it("by default returns true", async () => { + const result = await handler.handleMessage(msg.BLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.blockMessageById); + assert.isTrue(result); + }); + }); + describe("USER_ACTION action", () => { + it("default calls SpecialMessageActions.handleAction", async () => { + await handler.handleMessage( + msg.USER_ACTION, + { + type: "SOMETHING", + }, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.specialMessageActions.handleAction); + assert.calledWith( + config.specialMessageActions.handleAction, + { type: "SOMETHING" }, + { ownerGlobal: {} } + ); + }); + }); + describe("IMPRESSION action", () => { + it("default calls addImpression", () => { + handler.handleMessage(msg.IMPRESSION, { + id: 1, + }); + assert.calledOnce(config.router.addImpression); + }); + }); + describe("TRIGGER action", () => { + it("default calls sendTriggerMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.TRIGGER, + { + trigger: { stuff: {} }, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendTriggerMessage); + assert.calledWith(config.router.sendTriggerMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("NEWTAB_MESSAGE_REQUEST action", () => { + it("default calls sendNewTabMessage and returns state", async () => { + const result = await handler.handleMessage( + msg.NEWTAB_MESSAGE_REQUEST, + { + stuff: {}, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.sendNewTabMessage); + assert.calledWith(config.router.sendNewTabMessage, { + stuff: {}, + tabId: 100, + browser: { ownerGlobal: {} }, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("ADMIN_CONNECT_STATE action", () => { + it("with endpoint url calls addPreviewEndpoint, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE, { + endpoint: { + url: "test", + }, + }); + assert.calledOnce(config.router.addPreviewEndpoint); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + it("default returns state", async () => { + const result = await handler.handleMessage(msg.ADMIN_CONNECT_STATE); + assert.calledOnce(config.router.updateTargetingParameters); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_MESSAGE_BY_ID action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_MESSAGE_BY_ID, { + id: 1, + }); + assert.calledOnce(config.router.unblockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_ALL action", () => { + it("default calls unblockAll", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_ALL); + assert.calledOnce(config.router.unblockAll); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("BLOCK_BUNDLE action", () => { + it("default calls unblockMessageById", async () => { + const result = await handler.handleMessage(msg.BLOCK_BUNDLE, { + bundle: [ + { + id: 8, + }, + { + id: 13, + }, + ], + }); + assert.calledOnce(config.router.blockMessageById); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("UNBLOCK_BUNDLE action", () => { + it("default calls setState", async () => { + const result = await handler.handleMessage(msg.UNBLOCK_BUNDLE, { + bundle: [ + { + id: 1, + }, + { + id: 3, + }, + ], + }); + assert.calledOnce(config.router.setState); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("DISABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.DISABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("ENABLE_PROVIDER action", () => { + it("default calls ASRouterPreferences.enableOrDisableProvider", () => { + handler.handleMessage(msg.ENABLE_PROVIDER, {}); + assert.calledOnce(config.preferences.enableOrDisableProvider); + }); + }); + describe("EVALUATE_JEXL_EXPRESSION action", () => { + it("default calls evaluateExpression", () => { + handler.handleMessage(msg.EVALUATE_JEXL_EXPRESSION, {}); + assert.calledOnce(config.router.evaluateExpression); + }); + }); + describe("EXPIRE_QUERY_CACHE action", () => { + it("default calls QueryCache.expireAll", () => { + handler.handleMessage(msg.EXPIRE_QUERY_CACHE); + assert.calledOnce(config.queryCache.expireAll); + }); + }); + describe("FORCE_ATTRIBUTION action", () => { + it("default calls forceAttribution", () => { + handler.handleMessage(msg.FORCE_ATTRIBUTION, {}); + assert.calledOnce(config.router.forceAttribution); + }); + }); + describe("FORCE_WHATSNEW_PANEL action", () => { + it("default calls forceWNPanel", () => { + handler.handleMessage( + msg.FORCE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forceWNPanel); + assert.calledWith(config.router.forceWNPanel, { ownerGlobal: {} }); + }); + }); + describe("CLOSE_WHATSNEW_PANEL action", () => { + it("default calls closeWNPanel", () => { + handler.handleMessage( + msg.CLOSE_WHATSNEW_PANEL, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.closeWNPanel); + assert.calledWith(config.router.closeWNPanel, { ownerGlobal: {} }); + }); + }); + describe("FORCE_PRIVATE_BROWSING_WINDOW action", () => { + it("default calls forcePBWindow", () => { + handler.handleMessage( + msg.FORCE_PRIVATE_BROWSING_WINDOW, + {}, + { browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.forcePBWindow); + assert.calledWith(config.router.forcePBWindow, { ownerGlobal: {} }); + }); + }); + describe("MODIFY_MESSAGE_JSON action", () => { + it("default calls routeCFRMessage", async () => { + const result = await handler.handleMessage( + msg.MODIFY_MESSAGE_JSON, + { + content: { + text: "something", + }, + }, + { browser: { ownerGlobal: {} }, id: 100 } + ); + assert.calledOnce(config.router.routeCFRMessage); + assert.calledWith( + config.router.routeCFRMessage, + { text: "something" }, + { ownerGlobal: {} }, + { content: { text: "something" } }, + true + ); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("OVERRIDE_MESSAGE action", () => { + it("default calls setMessageById", async () => { + const result = await handler.handleMessage( + msg.OVERRIDE_MESSAGE, + { + id: 1, + }, + { id: 100, browser: { ownerGlobal: {} } } + ); + assert.calledOnce(config.router.setMessageById); + assert.calledWith(config.router.setMessageById, { id: 1 }, true, { + ownerGlobal: {}, + }); + assert.deepEqual(result, { value: 1 }); + }); + }); + describe("RESET_PROVIDER_PREF action", () => { + it("default calls ASRouterPreferences.resetProviderPref", () => { + handler.handleMessage(msg.RESET_PROVIDER_PREF); + assert.calledOnce(config.preferences.resetProviderPref); + }); + }); + describe("SET_PROVIDER_USER_PREF action", () => { + it("default calls ASRouterPreferences.setUserPreference", () => { + handler.handleMessage(msg.SET_PROVIDER_USER_PREF, { + id: 1, + value: true, + }); + assert.calledOnce(config.preferences.setUserPreference); + assert.calledWith(config.preferences.setUserPreference, 1, true); + }); + }); + describe("RESET_GROUPS_STATE action", () => { + it("default calls resetGroupsState, loadMessagesFromAllProviders, and returns state", async () => { + const result = await handler.handleMessage(msg.RESET_GROUPS_STATE, { + property: "value", + }); + assert.calledOnce(config.router.resetGroupsState); + assert.calledOnce(config.router.loadMessagesFromAllProviders); + assert.deepEqual(result, { value: 1 }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js new file mode 100644 index 0000000000..3ad759d6b9 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterPreferences.test.js @@ -0,0 +1,491 @@ +import { + _ASRouterPreferences, + ASRouterPreferences as ASRouterPreferencesSingleton, + TEST_PROVIDERS, +} from "lib/ASRouterPreferences.jsm"; +const FAKE_PROVIDERS = [{ id: "foo" }, { id: "bar" }]; + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; +const SNIPPETS_USER_PREF = "browser.newtabpage.activity-stream.feeds.snippets"; +const CFR_USER_PREF_ADDONS = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons"; +const CFR_USER_PREF_FEATURES = + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features"; + +/** NUMBER_OF_PREFS_TO_OBSERVE includes: + * 1. asrouter.providers. pref branch + * 2. asrouter.devtoolsEnabled + * 3. browser.newtabpage.activity-stream.feeds.snippets (user preference - snippets) + * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons (user preference - cfr) + * 4. browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features (user preference - cfr) + * 5. services.sync.username + */ +const NUMBER_OF_PREFS_TO_OBSERVE = 6; + +describe("ASRouterPreferences", () => { + let ASRouterPreferences; + let sandbox; + let addObserverStub; + let stringPrefStub; + let boolPrefStub; + let resetStub; + let hasUserValueStub; + let childListStub; + let setStringPrefStub; + + beforeEach(() => { + ASRouterPreferences = new _ASRouterPreferences(); + + sandbox = sinon.createSandbox(); + addObserverStub = sandbox.stub(global.Services.prefs, "addObserver"); + stringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + resetStub = sandbox.stub(global.Services.prefs, "clearUserPref"); + setStringPrefStub = sandbox.stub(global.Services.prefs, "setStringPref"); + FAKE_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(JSON.stringify(provider)); + }); + + boolPrefStub = sandbox + .stub(global.Services.prefs, "getBoolPref") + .returns(false); + + hasUserValueStub = sandbox + .stub(global.Services.prefs, "prefHasUserValue") + .returns(false); + + childListStub = sandbox.stub(global.Services.prefs, "getChildList"); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + FAKE_PROVIDERS.map(provider => `${PROVIDER_PREF_BRANCH}${provider.id}`) + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function getPrefNameForProvider(providerId) { + return `${PROVIDER_PREF_BRANCH}${providerId}`; + } + + function setPrefForProvider(providerId, value) { + stringPrefStub + .withArgs(getPrefNameForProvider(providerId)) + .returns(JSON.stringify(value)); + } + + it("ASRouterPreferences should be an instance of _ASRouterPreferences", () => { + assert.instanceOf(ASRouterPreferencesSingleton, _ASRouterPreferences); + }); + describe("#init", () => { + it("should set ._initialized to true", () => { + ASRouterPreferences.init(); + assert.isTrue(ASRouterPreferences._initialized); + }); + it("should migrate the provider prefs", () => { + ASRouterPreferences.uninit(); + // Should be migrated because they contain bucket and not collection + const MIGRATE_PROVIDERS = [ + { id: "baz", bucket: "buk" }, + { id: "qux", bucket: "buk" }, + ]; + // Should be cleared to defaults because it throws on setStringPref + const ERROR_PROVIDER = { id: "err", bucket: "buk" }; + // Should not be migrated because, although modified, it lacks bucket + const MODIFIED_SAFE_PROVIDER = { id: "safe" }; + const ALL_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + ...FAKE_PROVIDERS, // Should not be migrated because they're unmodified + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + // The migrator should attempt to read prefs for all of these providers + const TRY_PROVIDERS = [ + ...MIGRATE_PROVIDERS, + MODIFIED_SAFE_PROVIDER, + ERROR_PROVIDER, + ]; + + // Update the full list of provider prefs + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + ALL_PROVIDERS.map(provider => getPrefNameForProvider(provider.id)) + ); + // Stub the pref values so the migrator can read them + ALL_PROVIDERS.forEach(provider => { + stringPrefStub + .withArgs(getPrefNameForProvider(provider.id)) + .returns(JSON.stringify(provider)); + }); + + // Consider these providers' prefs "modified" + TRY_PROVIDERS.forEach(provider => { + hasUserValueStub + .withArgs(`${PROVIDER_PREF_BRANCH}${provider.id}`) + .returns(true); + }); + // Spoof an error when trying to set the pref for this provider so we can + // test that the pref is gracefully reset on error + setStringPrefStub + .withArgs(getPrefNameForProvider(ERROR_PROVIDER.id)) + .throws(); + + ASRouterPreferences.init(); + + // The migrator should have tried to check each pref for user modification + ALL_PROVIDERS.forEach(provider => + assert.calledWith(hasUserValueStub, getPrefNameForProvider(provider.id)) + ); + // Test that we don't call getStringPref for providers that don't have a + // user-defined value + FAKE_PROVIDERS.forEach(provider => + assert.neverCalledWith( + stringPrefStub, + getPrefNameForProvider(provider.id) + ) + ); + // But we do call it for providers that do have a user-defined value + TRY_PROVIDERS.forEach(provider => + assert.calledWith(stringPrefStub, getPrefNameForProvider(provider.id)) + ); + + // Test that we don't call setStringPref to migrate providers that don't + // have a bucket property + assert.neverCalledWith( + setStringPrefStub, + getPrefNameForProvider(MODIFIED_SAFE_PROVIDER.id) + ); + + /** + * For a given provider, return a sinon matcher that matches if the value + * looks like a migrated version of the original provider. Requires that: + * its id matches the original provider's id; it has no bucket; and its + * collection is set to the value of the original provider's bucket. + * @param {object} provider the provider object to compare to + * @returns {object} custom matcher object for sinon + */ + function providerJsonMatches(provider) { + return sandbox.match(migrated => { + const parsed = JSON.parse(migrated); + return ( + parsed.id === provider.id && + !("bucket" in parsed) && + parsed.collection === provider.bucket + ); + }); + } + + // Test that we call setStringPref to migrate providers that have a bucket + // property and don't have a collection property + MIGRATE_PROVIDERS.forEach(provider => + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider(provider.id), + providerJsonMatches(provider) // Verify the migrated pref value + ) + ); + + // Test that we clear the pref for providers that throw when we try to + // read or write them + assert.calledWith(resetStub, getPrefNameForProvider(ERROR_PROVIDER.id)); + }); + it(`should set ${NUMBER_OF_PREFS_TO_OBSERVE} observers and not re-initialize if already initialized`, () => { + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + ASRouterPreferences.init(); + ASRouterPreferences.init(); + assert.callCount(addObserverStub, NUMBER_OF_PREFS_TO_OBSERVE); + }); + }); + describe("#uninit", () => { + it("should set ._initialized to false", () => { + ASRouterPreferences.init(); + ASRouterPreferences.uninit(); + assert.isFalse(ASRouterPreferences._initialized); + }); + it("should clear cached values for ._initialized, .devtoolsEnabled", () => { + ASRouterPreferences.init(); + // trigger caching + // eslint-disable-next-line no-unused-vars + const result = [ + ASRouterPreferences.providers, + ASRouterPreferences.devtoolsEnabled, + ]; + assert.isNotNull( + ASRouterPreferences._providers, + "providers should not be null" + ); + assert.isNotNull( + ASRouterPreferences._devtoolsEnabled, + "devtolosEnabled should not be null" + ); + + ASRouterPreferences.uninit(); + assert.isNull(ASRouterPreferences._providers); + assert.isNull(ASRouterPreferences._devtoolsEnabled); + }); + it("should clear all listeners and remove observers (only once)", () => { + const removeStub = sandbox.stub(global.Services.prefs, "removeObserver"); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(() => {}); + ASRouterPreferences.addListener(() => {}); + assert.equal(ASRouterPreferences._callbacks.size, 2); + ASRouterPreferences.uninit(); + // Tests to make sure we don't remove observers that weren't set + ASRouterPreferences.uninit(); + + assert.callCount(removeStub, NUMBER_OF_PREFS_TO_OBSERVE); + assert.calledWith(removeStub, PROVIDER_PREF_BRANCH); + assert.calledWith(removeStub, DEVTOOLS_PREF); + assert.isEmpty(ASRouterPreferences._callbacks); + }); + }); + describe(".providers", () => { + it("should return the value the first time .providers is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.providers; + assert.deepEqual(result, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should return the cached value the second time .providers is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(secondCall, FAKE_PROVIDERS); + // once per pref + assert.calledTwice(stringPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.providers, + ASRouterPreferences.providers, + ]; + + assert.deepEqual(firstCall, FAKE_PROVIDERS); + assert.deepEqual(secondCall, FAKE_PROVIDERS); + assert.callCount(stringPrefStub, 4); + }); + it("should skip the pref without throwing if a pref is not parsable", () => { + stringPrefStub.withArgs(`${PROVIDER_PREF_BRANCH}foo`).returns("not json"); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [{ id: "bar" }]); + }); + it("should include TEST_PROVIDERS if devtools is turned on", () => { + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, [ + ...TEST_PROVIDERS, + ...FAKE_PROVIDERS, + ]); + }); + }); + describe(".devtoolsEnabled", () => { + it("should read the pref the first time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + + const result = ASRouterPreferences.devtoolsEnabled; + assert.deepEqual(result, false); + assert.calledOnce(boolPrefStub); + }); + it("should return the cached value the second time .devtoolsEnabled is accessed", () => { + ASRouterPreferences.init(); + const [, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(secondCall, false); + assert.calledOnce(boolPrefStub); + }); + it("should just parse the pref each time if ASRouterPreferences hasn't been initialized yet", () => { + // Intentionally not initialized + const [firstCall, secondCall] = [ + ASRouterPreferences.devtoolsEnabled, + ASRouterPreferences.devtoolsEnabled, + ]; + + assert.deepEqual(firstCall, false); + assert.deepEqual(secondCall, false); + assert.calledTwice(boolPrefStub); + }); + }); + describe("#getUserPreference(providerId)", () => { + it("should return the user preference for snippets", () => { + boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true); + assert.isTrue(ASRouterPreferences.getUserPreference("snippets")); + }); + }); + describe("#getAllUserPreferences", () => { + it("should return all user preferences", () => { + boolPrefStub.withArgs(SNIPPETS_USER_PREF).returns(true); + boolPrefStub.withArgs(CFR_USER_PREF_ADDONS).returns(false); + boolPrefStub.withArgs(CFR_USER_PREF_FEATURES).returns(true); + const result = ASRouterPreferences.getAllUserPreferences(); + assert.deepEqual(result, { + snippets: true, + cfrAddons: false, + cfrFeatures: true, + }); + }); + }); + describe("#enableOrDisableProvider", () => { + it("should enable an existing provider if second param is true", () => { + setPrefForProvider("foo", { id: "foo", enabled: false }); + assert.isFalse(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", true); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: true }) + ); + }); + it("should disable an existing provider if second param is false", () => { + setPrefForProvider("foo", { id: "foo", enabled: true }); + assert.isTrue(ASRouterPreferences.providers[0].enabled); + + ASRouterPreferences.enableOrDisableProvider("foo", false); + + assert.calledWith( + setStringPrefStub, + getPrefNameForProvider("foo"), + JSON.stringify({ id: "foo", enabled: false }) + ); + }); + it("should not throw if the id does not exist", () => { + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("does_not_exist", true); + }); + }); + it("should not throw if pref is not parseable", () => { + stringPrefStub + .withArgs(getPrefNameForProvider("foo")) + .returns("not valid"); + assert.doesNotThrow(() => { + ASRouterPreferences.enableOrDisableProvider("foo", true); + }); + }); + }); + describe("#setUserPreference", () => { + it("should do nothing if the pref doesn't exist", () => { + ASRouterPreferences.setUserPreference("foo", true); + assert.notCalled(boolPrefStub); + }); + it("should set the given pref", () => { + const setStub = sandbox.stub(global.Services.prefs, "setBoolPref"); + ASRouterPreferences.setUserPreference("snippets", true); + assert.calledWith(setStub, SNIPPETS_USER_PREF, true); + }); + }); + describe("#resetProviderPref", () => { + it("should reset the pref and user prefs", () => { + ASRouterPreferences.resetProviderPref(); + FAKE_PROVIDERS.forEach(provider => { + assert.calledWith(resetStub, getPrefNameForProvider(provider.id)); + }); + assert.calledWith(resetStub, SNIPPETS_USER_PREF); + assert.calledWith(resetStub, CFR_USER_PREF_ADDONS); + assert.calledWith(resetStub, CFR_USER_PREF_FEATURES); + }); + }); + describe("observer, listeners", () => { + it("should invalidate .providers when the pref is changed", () => { + const testProvider = { id: "newstuff" }; + const newProviders = [...FAKE_PROVIDERS, testProvider]; + + ASRouterPreferences.init(); + + assert.deepEqual(ASRouterPreferences.providers, FAKE_PROVIDERS); + stringPrefStub + .withArgs(getPrefNameForProvider(testProvider.id)) + .returns(JSON.stringify(testProvider)); + childListStub + .withArgs(PROVIDER_PREF_BRANCH) + .returns( + newProviders.map(provider => getPrefNameForProvider(provider.id)) + ); + ASRouterPreferences.observe( + null, + null, + getPrefNameForProvider(testProvider.id) + ); + + // Cache should be invalidated so we access the new value of the pref now + assert.deepEqual(ASRouterPreferences.providers, newProviders); + }); + it("should invalidate .devtoolsEnabled and .providers when the pref is changed", () => { + ASRouterPreferences.init(); + + assert.isFalse(ASRouterPreferences.devtoolsEnabled); + boolPrefStub.withArgs(DEVTOOLS_PREF).returns(true); + childListStub.withArgs(PROVIDER_PREF_BRANCH).returns([]); + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + + // Cache should be invalidated so we access the new value of the pref now + // Note that providers needs to be invalidated because devtools adds test content to it. + assert.isTrue(ASRouterPreferences.devtoolsEnabled); + assert.deepEqual(ASRouterPreferences.providers, TEST_PROVIDERS); + }); + it("should call listeners added with .addListener", () => { + const callback1 = sinon.stub(); + const callback2 = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback1); + ASRouterPreferences.addListener(callback2); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback1, getPrefNameForProvider("foo")); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.calledWith(callback2, DEVTOOLS_PREF); + }); + it("should not call listeners after they are removed with .removeListeners", () => { + const callback = sinon.stub(); + ASRouterPreferences.init(); + ASRouterPreferences.addListener(callback); + + ASRouterPreferences.observe(null, null, getPrefNameForProvider("foo")); + assert.calledWith(callback, getPrefNameForProvider("foo")); + + callback.reset(); + ASRouterPreferences.removeListener(callback); + + ASRouterPreferences.observe(null, null, DEVTOOLS_PREF); + assert.notCalled(callback); + }); + }); + describe("#_transformPersonalizedCfrScores", () => { + it("should report JSON.parse errors", () => { + sandbox.stub(global.console, "error"); + + ASRouterPreferences._transformPersonalizedCfrScores(""); + + assert.calledOnce(global.console.error); + }); + it("should return an object parsed from a string", () => { + const scores = { FOO: 3000, BAR: 4000 }; + assert.deepEqual( + ASRouterPreferences._transformPersonalizedCfrScores( + JSON.stringify(scores) + ), + scores + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js new file mode 100644 index 0000000000..a6e0eea3af --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterTargeting.test.js @@ -0,0 +1,574 @@ +import { + ASRouterTargeting, + CachedTargetingGetter, + getSortedMessages, + QueryCache, +} from "lib/ASRouterTargeting.jsm"; +import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm"; +import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +// Note that tests for the ASRouterTargeting environment can be found in +// test/functional/mochitest/browser_asrouter_targeting.js + +describe("#CachedTargetingGetter", () => { + const sixHours = 6 * 60 * 60 * 1000; + let sandbox; + let clock; + let frecentStub; + let topsitesCache; + let globals; + let doesAppNeedPinStub; + let getAddonsByTypesStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + frecentStub = sandbox.stub( + global.NewTabUtils.activityStreamProvider, + "getTopFrecentSites" + ); + topsitesCache = new CachedTargetingGetter("getTopFrecentSites"); + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return sinon.stub(); + } + + evalWithDefault(expr) { + return sinon.stub(); + } + } + ); + doesAppNeedPinStub = sandbox.stub().resolves(); + getAddonsByTypesStub = sandbox.stub().resolves(); + }); + + afterEach(() => { + sandbox.restore(); + clock.restore(); + globals.restore(); + }); + + it("should cache allow for optional getter argument", async () => { + let pinCachedGetter = new CachedTargetingGetter( + "doesAppNeedPin", + true, + undefined, + { doesAppNeedPin: doesAppNeedPinStub } + ); + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await pinCachedGetter.get(); + await pinCachedGetter.get(); + await pinCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(doesAppNeedPinStub); + + // Called with option argument + assert.calledWith(doesAppNeedPinStub, true); + + // Expire and call again + clock.tick(sixHours); + await pinCachedGetter.get(); + + // Call goes through + assert.calledTwice(doesAppNeedPinStub); + + let themesCachedGetter = new CachedTargetingGetter( + "getAddonsByTypes", + ["foo"], + undefined, + { getAddonsByTypes: getAddonsByTypesStub } + ); + + // Need to tick forward because Date.now() is stubbed + clock.tick(sixHours); + + await themesCachedGetter.get(); + await themesCachedGetter.get(); + await themesCachedGetter.get(); + + // Called once; cached request + assert.calledOnce(getAddonsByTypesStub); + + // Called with option argument + assert.calledWith(getAddonsByTypesStub, ["foo"]); + + // Expire and call again + clock.tick(sixHours); + await themesCachedGetter.get(); + + // Call goes through + assert.calledTwice(getAddonsByTypesStub); + }); + + it("should only make a request every 6 hours", async () => { + frecentStub.resolves(); + clock.tick(sixHours); + + await topsitesCache.get(); + await topsitesCache.get(); + + assert.calledOnce( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + + clock.tick(sixHours); + + await topsitesCache.get(); + + assert.calledTwice( + global.NewTabUtils.activityStreamProvider.getTopFrecentSites + ); + }); + it("throws when failing getter", async () => { + frecentStub.rejects(new Error("fake error")); + clock.tick(sixHours); + + // assert.throws expect a function as the first parameter, try/catch is a + // workaround + let rejected = false; + try { + await topsitesCache.get(); + } catch (e) { + rejected = true; + } + + assert(rejected); + }); + describe("sortMessagesByPriority", () => { + it("should sort messages in descending priority order", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: 1 }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m2.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m1.id); + }); + it("should sort messages with no priority last", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 0 }, + { ...m2, priority: undefined }, + { ...m3, priority: 2 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m3.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m1.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + it("should keep the order of messages with same priority unchanged", async () => { + const [m1, m2, m3 = { id: "m3" }] = + await OnboardingMessageProvider.getUntranslatedMessages(); + const checkMessageTargetingStub = sandbox + .stub(ASRouterTargeting, "checkMessageTargeting") + .resolves(false); + sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); + + await ASRouterTargeting.findMatchingMessage({ + messages: [ + { ...m1, priority: 2, targeting: undefined, rank: 1 }, + { ...m2, priority: undefined, targeting: undefined, rank: 1 }, + { ...m3, priority: 2, targeting: undefined, rank: 1 }, + ], + trigger: "testing", + }); + + assert.equal(checkMessageTargetingStub.callCount, 3); + + const [arg_m1] = checkMessageTargetingStub.firstCall.args; + assert.equal(arg_m1.id, m1.id); + + const [arg_m2] = checkMessageTargetingStub.secondCall.args; + assert.equal(arg_m2.id, m3.id); + + const [arg_m3] = checkMessageTargetingStub.thirdCall.args; + assert.equal(arg_m3.id, m2.id); + }); + }); +}); +describe("#isTriggerMatch", () => { + let trigger; + let message; + beforeEach(() => { + trigger = { id: "openURL" }; + message = { id: "openURL" }; + }); + it("should return false if trigger and candidate ids are different", () => { + trigger.id = "trigger"; + message.id = "message"; + + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + assert.isTrue( + ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" }) + ); + }); + it("should return true if the message we check doesn't have trigger params or patterns", () => { + // No params or patterns defined + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return false if the trigger does not have params defined", () => { + message.params = {}; + + // trigger.param is undefined + assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger host", () => { + message.params = ["mozilla.org"]; + trigger.param = { host: "mozilla.org" }; + + assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); + }); + it("should return true if message params includes trigger param.type", () => { + message.params = ["ContentBlockingMilestone"]; + trigger.param = { type: "ContentBlockingMilestone" }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); + it("should return true if message params match trigger mask", () => { + // STATE_BLOCKED_FINGERPRINTING_CONTENT + message.params = [0x00000040]; + trigger.param = { type: 538091584 }; + + assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); + }); +}); +describe("#CacheListAttachedOAuthClients", () => { + const fourHours = 4 * 60 * 60 * 1000; + let sandbox; + let clock; + let fakeFxAccount; + let authClientsCache; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fakeFxAccount = { + listAttachedOAuthClients: () => {}, + }; + globals.set("fxAccounts", fakeFxAccount); + authClientsCache = QueryCache.queries.ListAttachedOAuthClients; + sandbox + .stub(global.fxAccounts, "listAttachedOAuthClients") + .returns(Promise.resolve({})); + }); + + afterEach(() => { + authClientsCache.expire(); + sandbox.restore(); + clock.restore(); + }); + + it("should only make additional request every 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + clock.tick(fourHours); + await authClientsCache.get(); + assert.calledTwice(global.fxAccounts.listAttachedOAuthClients); + }); + + it("should not make additional request before 4 hours", async () => { + clock.tick(fourHours); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + + await authClientsCache.get(); + assert.calledOnce(global.fxAccounts.listAttachedOAuthClients); + }); +}); +describe("ASRouterTargeting", () => { + let evalStub; + let sandbox; + let clock; + let globals; + let fakeTargetingContext; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.replace(ASRouterTargeting, "Environment", {}); + clock = sinon.useFakeTimers(); + fakeTargetingContext = { + combineContexts: sandbox.stub(), + evalWithDefault: sandbox.stub().resolves(), + setTelemetrySource: sandbox.stub(), + }; + globals = new GlobalOverrider(); + globals.set( + "TargetingContext", + class { + static combineContexts(...args) { + return fakeTargetingContext.combineContexts.apply(sandbox, args); + } + + setTelemetrySource(id) { + fakeTargetingContext.setTelemetrySource(id); + } + + evalWithDefault(expr) { + return fakeTargetingContext.evalWithDefault(expr); + } + } + ); + evalStub = fakeTargetingContext.evalWithDefault; + }); + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + it("should provide message.id as source", async () => { + await ASRouterTargeting.checkMessageTargeting( + { + id: "message", + targeting: "true", + }, + fakeTargetingContext, + sandbox.stub(), + false + ); + assert.calledOnce(fakeTargetingContext.evalWithDefault); + assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true"); + assert.calledWithExactly( + fakeTargetingContext.setTelemetrySource, + "message" + ); + }); + it("should cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl2" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl1" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + it("should not cache evaluation result", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + false + ); + + assert.calledThrice(evalStub); + }); + it("should expire cache entries", async () => { + evalStub.resolves(true); + let targetingContext = new global.TargetingContext(); + + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + clock.tick(5 * 60 * 1000 + 1); + await ASRouterTargeting.checkMessageTargeting( + { targeting: "jexl" }, + targetingContext, + sandbox.stub(), + true + ); + + assert.calledTwice(evalStub); + }); + + describe("#findMatchingMessage", () => { + let matchStub; + let messages = [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + { id: "BAZ" }, + ]; + beforeEach(() => { + matchStub = sandbox + .stub(ASRouterTargeting, "_isMessageMatch") + .callsFake(message => message.targeting === "match"); + }); + it("should return an array of matches if returnAll is true", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [ + { id: "FOO", targeting: "match" }, + { id: "BAR", targeting: "match" }, + ] + ); + }); + it("should return an empty array if no matches were found and returnAll is true", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + returnAll: true, + }), + [] + ); + }); + it("should return the first match if returnAll is false", async () => { + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + messages[0] + ); + }); + it("should return null if if no matches were found and returnAll is false", async () => { + matchStub.returns(false); + assert.deepEqual( + await ASRouterTargeting.findMatchingMessage({ + messages, + }), + null + ); + }); + }); +}); + +/** + * Messages should be sorted in the following order: + * 1. Rank + * 2. Priority + * 3. If the message has targeting + * 4. Order or randomization, depending on input + */ +describe("getSortedMessages", () => { + let globals = new GlobalOverrider(); + let sandbox; + beforeEach(() => { + globals.set({ ASRouterPreferences }); + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + /** + * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages, + * returns the items in the expected order. + * + * @param {Message[]} expectedOrderArray - The array of messages in its expected order + * @param {{}} options - The options param for getSortedMessages + * @returns + */ + function assertSortsCorrectly(expectedOrderArray, options) { + const input = [...expectedOrderArray].reverse(); + const result = getSortedMessages(input, options); + const indexes = result.map(message => expectedOrderArray.indexOf(message)); + return assert.equal( + indexes.join(","), + [...expectedOrderArray.keys()].join(","), + "Messsages are out of order" + ); + } + + it("should sort messages by priority, then by targeting", () => { + assertSortsCorrectly([ + { priority: 100, targeting: "isFoo" }, + { priority: 100 }, + { priority: 99 }, + { priority: 1, targeting: "isFoo" }, + { priority: 1 }, + {}, + ]); + }); + it("should sort messages by priority, then targeting, then order if ordered param is true", () => { + assertSortsCorrectly( + [ + { priority: 100, order: 4 }, + { priority: 100, order: 5 }, + { priority: 1, order: 3, targeting: "isFoo" }, + { priority: 1, order: 0 }, + { priority: 1, order: 1 }, + { priority: 1, order: 2 }, + { order: 0 }, + ], + { ordered: true } + ); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js new file mode 100644 index 0000000000..52a7785e05 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ASRouterTriggerListeners.test.js @@ -0,0 +1,778 @@ +import { ASRouterTriggerListeners } from "lib/ASRouterTriggerListeners.jsm"; +import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterTriggerListeners", () => { + let sandbox; + let globals; + let existingWindow; + let isWindowPrivateStub; + const triggerHandler = () => {}; + const openURLListener = ASRouterTriggerListeners.get("openURL"); + const frequentVisitsListener = ASRouterTriggerListeners.get("frequentVisits"); + const captivePortalLoginListener = + ASRouterTriggerListeners.get("captivePortalLogin"); + const bookmarkedURLListener = + ASRouterTriggerListeners.get("openBookmarkedURL"); + const openArticleURLListener = ASRouterTriggerListeners.get("openArticleURL"); + const nthTabClosedListener = ASRouterTriggerListeners.get("nthTabClosed"); + const idleListener = ASRouterTriggerListeners.get("activityAfterIdle"); + const formAutofillListener = ASRouterTriggerListeners.get("formAutofill"); + const cookieBannerDetectedListener = ASRouterTriggerListeners.get( + "cookieBannerDetected" + ); + const hosts = ["www.mozilla.com", "www.mozilla.org"]; + + const regionFake = { + _home: "cn", + _current: "cn", + get home() { + return this._home; + }, + get current() { + return this._current; + }, + }; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + existingWindow = { + gBrowser: { + addTabsProgressListener: sandbox.stub(), + removeTabsProgressListener: sandbox.stub(), + currentURI: { host: "" }, + }, + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }; + sandbox.spy(openURLListener, "init"); + sandbox.spy(openURLListener, "uninit"); + isWindowPrivateStub = sandbox.stub(); + // Assume no window is private so that we execute the action + isWindowPrivateStub.returns(false); + globals.set("PrivateBrowsingUtils", { + isWindowPrivate: isWindowPrivateStub, + }); + const ewUninit = new Map(); + globals.set("EveryWindow", { + registerCallback: (id, init, uninit) => { + init(existingWindow); + ewUninit.set(id, uninit); + }, + unregisterCallback: id => { + ewUninit.get(id)(existingWindow); + }, + }); + globals.set("Region", regionFake); + globals.set("ASRouterPreferences", ASRouterPreferences); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("openBookmarkedURL", () => { + let observerStub; + describe("#init", () => { + beforeEach(() => { + observerStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getMostRecentBrowserWindow") + .returns({ gBrowser: { selectedBrowser: {} } }); + }); + afterEach(() => { + bookmarkedURLListener.uninit(); + }); + it("should set hosts to the recentBookmarks", async () => { + await bookmarkedURLListener.init(sandbox.stub()); + + assert.calledOnce(observerStub); + assert.calledWithExactly( + observerStub, + bookmarkedURLListener, + "bookmark-icon-updated" + ); + }); + it("should provide id to triggerHandler", async () => { + const newTriggerHandler = sinon.stub(); + const subject = {}; + await bookmarkedURLListener.init(newTriggerHandler); + + bookmarkedURLListener.observe( + subject, + "bookmark-icon-updated", + "starred" + ); + + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, subject, { + id: bookmarkedURLListener.id, + }); + }); + }); + }); + + describe("captivePortal", () => { + describe("observe", () => { + it("should not call the trigger handler if _shouldShowCaptivePortalVPNPromo returns false", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(false); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.notCalled(captivePortalLoginListener._triggerHandler); + }); + + it("should call the trigger handler if _shouldShowCaptivePortalVPNPromo returns true", () => { + sandbox + .stub(captivePortalLoginListener, "_shouldShowCaptivePortalVPNPromo") + .returns(true); + sandbox.stub(Services.wm, "getMostRecentBrowserWindow").returns({ + gBrowser: { + selectedBrowser: true, + }, + }); + captivePortalLoginListener._triggerHandler = sandbox.spy(); + + captivePortalLoginListener.observe( + null, + "captive-portal-login-success" + ); + + assert.calledOnce(captivePortalLoginListener._triggerHandler); + }); + }); + }); + + describe("openArticleURL", () => { + describe("#init", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ + patterns, + matches: url => patterns.has(url), + })) + ); + sandbox.stub(global.AboutReaderParent, "addMessageListener"); + sandbox.stub(global.AboutReaderParent, "removeMessageListener"); + }); + afterEach(() => { + openArticleURLListener.uninit(); + }); + it("setup an event listener on init", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + + assert.calledOnce(global.AboutReaderParent.addMessageListener); + assert.calledWithExactly( + global.AboutReaderParent.addMessageListener, + openArticleURLListener.readerModeEvent, + sinon.match.object + ); + }); + it("should call triggerHandler correctly for matches [host match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: hosts[0], spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: hosts[0], url: hosts[1] }, + }); + }); + it("should call triggerHandler correctly for matches [pattern match]", () => { + const stub = sandbox.stub(); + const target = { currentURI: { host: null, spec: hosts[1] } }; + openArticleURLListener.init(stub, hosts, hosts); + + const [, { receiveMessage }] = + global.AboutReaderParent.addMessageListener.firstCall.args; + receiveMessage({ data: { isArticle: true }, target }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, target, { + id: openArticleURLListener.id, + param: { host: null, url: hosts[1] }, + }); + }); + it("should remove the message listener", () => { + openArticleURLListener.init(sandbox.stub(), hosts, hosts); + openArticleURLListener.uninit(); + + assert.calledOnce(global.AboutReaderParent.removeMessageListener); + }); + }); + }); + + describe("frequentVisits", () => { + let _triggerHandler; + beforeEach(() => { + _triggerHandler = sandbox.stub(); + sandbox.useFakeTimers(); + frequentVisitsListener.init(_triggerHandler, hosts); + }); + afterEach(() => { + sandbox.clock.restore(); + frequentVisitsListener.uninit(); + }); + it("should be initialized", () => { + assert.isTrue(frequentVisitsListener._initialized); + }); + it("should listen for TabSelect events", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith( + existingWindow.addEventListener, + "TabSelect", + frequentVisitsListener.onTabSwitch + ); + }); + it("should call _triggerHandler if the visit is valid (is recoreded)", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler only once", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledOnce(_triggerHandler); + }); + it("should call _triggerHandler again after 15 minutes", () => { + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + sandbox.clock.tick(15 * 60 * 1000 + 1); + frequentVisitsListener.triggerHandler({}, "www.mozilla.com"); + + assert.calledTwice(_triggerHandler); + }); + it("should call triggerHandler on valid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = hosts[0]; // eslint-disable-line prefer-destructuring + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.calledOnce(stub); + }); + it("should not call triggerHandler on invalid hosts", () => { + const stub = sandbox.stub(frequentVisitsListener, "triggerHandler"); + existingWindow.gBrowser.currentURI.host = "foo.com"; + + frequentVisitsListener.onTabSwitch({ + target: { ownerGlobal: existingWindow }, + }); + + assert.notCalled(stub); + }); + describe("MatchPattern", () => { + beforeEach(() => { + globals.set( + "MatchPatternSet", + sandbox.stub().callsFake(patterns => ({ patterns: patterns || [] })) + ); + }); + afterEach(() => { + frequentVisitsListener.uninit(); + }); + it("should create a matchPatternSet", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + + assert.calledOnce(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern"]), + undefined + ); + }); + it("should allow to add multiple patterns and dedupe", () => { + frequentVisitsListener.init(_triggerHandler, hosts, ["pattern"]); + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.calledTwice(window.MatchPatternSet); + assert.calledWithExactly( + window.MatchPatternSet, + new Set(["pattern", "foo"]), + undefined + ); + }); + it("should handle bad arguments to MatchPatternSet", () => { + const badArgs = ["www.example.com"]; + window.MatchPatternSet.withArgs(new Set(badArgs)).throws(); + frequentVisitsListener.init(_triggerHandler, hosts, badArgs); + + // Fails with an empty MatchPatternSet + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + + // Second try is succesful + frequentVisitsListener.init(_triggerHandler, hosts, ["foo"]); + + assert.property(frequentVisitsListener._matchPatternSet, "patterns"); + assert.isTrue( + frequentVisitsListener._matchPatternSet.patterns.has("foo") + ); + }); + }); + }); + + describe("nthTabClosed", () => { + describe("#init", () => { + beforeEach(() => { + nthTabClosedListener.init(triggerHandler); + }); + afterEach(() => { + nthTabClosedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + nthTabClosedListener.init(newTriggerHandler); + assert.ok(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, newTriggerHandler); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + assert.calledWith(existingWindow.addEventListener, "TabClose"); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + nthTabClosedListener.init(triggerHandler); + nthTabClosedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler, closed tabs count", () => { + assert.notOk(nthTabClosedListener._initialized); + assert.equal(nthTabClosedListener._triggerHandler, null); + assert.equal(nthTabClosedListener._closedTabs, 0); + }); + + it("should do nothing if already uninitialised", () => { + nthTabClosedListener.uninit(); + assert.notOk(nthTabClosedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.removeEventListener); + }); + }); + }); + + describe("activityAfterIdle", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + sandbox + .stub(global.Services.wm, "getEnumerator") + .returns([{ closed: false, document: { hidden: false } }]); + idleListener.init(triggerHandler); + }); + afterEach(() => { + idleListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + idleListener.init(newTriggerHandler); + assert.ok(idleListener._initialized); + assert.equal(idleListener._triggerHandler, newTriggerHandler); + }); + + it("should add observers for idle and activity", () => { + assert.called(addObsStub); + }); + + it("should add event listeners to all existing browser windows", () => { + assert.called(existingWindow.addEventListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + sandbox.stub(global.Services.wm, "getEnumerator").returns([]); + idleListener.init(triggerHandler); + idleListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(idleListener._initialized); + assert.equal(idleListener._triggerHandler, null); + assert.equal(idleListener._quietSince, null); + }); + + it("should do nothing if already uninitialised", () => { + idleListener.uninit(); + assert.notOk(idleListener._initialized); + }); + + it("should remove observers for idle and activity", () => { + assert.called(removeObsStub); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); + + describe("formAutofill", () => { + let addObsStub; + let removeObsStub; + describe("#init", () => { + beforeEach(() => { + addObsStub = sandbox.stub(global.Services.obs, "addObserver"); + formAutofillListener.init(triggerHandler); + }); + afterEach(() => { + formAutofillListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, triggerHandler); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + formAutofillListener.init(newTriggerHandler); + assert.ok(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, newTriggerHandler); + }); + + it(`should add observer for ${formAutofillListener._topic}`, () => { + assert.called(addObsStub); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + removeObsStub = sandbox.stub(global.Services.obs, "removeObserver"); + formAutofillListener.init(triggerHandler); + formAutofillListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler", () => { + assert.notOk(formAutofillListener._initialized); + assert.equal(formAutofillListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + formAutofillListener.uninit(); + assert.notOk(formAutofillListener._initialized); + }); + + it(`should remove observers for ${formAutofillListener._topic}`, () => { + assert.called(removeObsStub); + }); + }); + }); + + describe("openURL listener", () => { + it("should exist and initially be uninitialised", () => { + assert.ok(openURLListener); + assert.notOk(openURLListener._initialized); + }); + + describe("#init", () => { + beforeEach(() => { + openURLListener.init(triggerHandler, hosts); + }); + afterEach(() => { + openURLListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler and hosts", () => { + assert.ok(openURLListener._initialized); + assert.deepEqual(openURLListener._hosts, new Set(hosts)); + assert.equal(openURLListener._triggerHandler, triggerHandler); + }); + + it("should add tab progress listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.addTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.addTabsProgressListener, + openURLListener + ); + }); + + it("if already initialised, should only update the trigger handler and add the new hosts", () => { + const newHosts = ["www.example.com"]; + const newTriggerHandler = () => {}; + existingWindow.gBrowser.addTabsProgressListener.reset(); + + openURLListener.init(newTriggerHandler, newHosts); + assert.ok(openURLListener._initialized); + assert.deepEqual( + openURLListener._hosts, + new Set([...hosts, ...newHosts]) + ); + assert.equal(openURLListener._triggerHandler, newTriggerHandler); + assert.notCalled(existingWindow.gBrowser.addTabsProgressListener); + }); + }); + + describe("#uninit", () => { + beforeEach(async () => { + openURLListener.init(triggerHandler, hosts); + openURLListener.uninit(); + }); + + it("should set ._initialized to false and clear the triggerHandler and hosts", () => { + assert.notOk(openURLListener._initialized); + assert.equal(openURLListener._hosts, null); + assert.equal(openURLListener._triggerHandler, null); + }); + + it("should remove tab progress listeners from all existing browser windows", () => { + assert.calledOnce(existingWindow.gBrowser.removeTabsProgressListener); + assert.calledWithExactly( + existingWindow.gBrowser.removeTabsProgressListener, + openURLListener + ); + }); + + it("should do nothing if already uninitialised", () => { + existingWindow.gBrowser.removeTabsProgressListener.reset(); + + openURLListener.uninit(); + assert.notOk(openURLListener._initialized); + assert.notCalled(existingWindow.gBrowser.removeTabsProgressListener); + }); + }); + + describe("#onLocationChange", () => { + afterEach(() => { + openURLListener.uninit(); + frequentVisitsListener.uninit(); + }); + + it("should call the ._triggerHandler with the right arguments", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const location = "www.mozilla.org"; + openURLListener.onLocationChange(browser, webProgress, undefined, { + host: location, + spec: location, + }); + assert.calledOnce(newTriggerHandler); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should call triggerHandler for a redirect (openURL + frequentVisits)", () => { + for (let trigger of [openURLListener, frequentVisitsListener]) { + const newTriggerHandler = sinon.stub(); + trigger.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + trigger.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.calledOnce(newTriggerHandler); + } + }); + it("should call triggerHandler with the right arguments (redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { spec: "www.mozilla.org", host: "www.mozilla.org" }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledWithExactly(newTriggerHandler, browser, { + id: "openURL", + param: { host: "www.mozilla.org", url: "www.mozilla.org" }, + context: { visitsCount: 1 }, + }); + }); + it("should fail for subdomains (not redirect)", () => { + const newTriggerHandler = sinon.stub(); + openURLListener.init(newTriggerHandler, hosts); + + const browser = {}; + const webProgress = { isTopLevel: true }; + const aLocationURI = { + host: "subdomain.mozilla.org", + spec: "subdomain.mozilla.org", + }; + const aRequest = { + QueryInterface: sandbox.stub().returns({ + originalURI: { + spec: "subdomain.mozilla.org", + host: "subdomain.mozilla.org", + }, + }), + }; + openURLListener.onLocationChange( + browser, + webProgress, + aRequest, + aLocationURI + ); + assert.calledOnce(aRequest.QueryInterface); + assert.notCalled(newTriggerHandler); + }); + }); + }); + + describe("cookieBannerDetected", () => { + describe("#init", () => { + beforeEach(() => { + cookieBannerDetectedListener.init(triggerHandler); + }); + afterEach(() => { + cookieBannerDetectedListener.uninit(); + }); + + it("should set ._initialized to true and save the triggerHandler", () => { + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + triggerHandler + ); + }); + + it("if already initialised, it should only update the trigger handler", () => { + const newTriggerHandler = () => {}; + cookieBannerDetectedListener.init(newTriggerHandler); + assert.ok(cookieBannerDetectedListener._initialized); + assert.equal( + cookieBannerDetectedListener._triggerHandler, + newTriggerHandler + ); + }); + + it("should add an event listeners to all existing browser windows", () => { + assert.calledOnce(existingWindow.addEventListener); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + cookieBannerDetectedListener.init(triggerHandler); + cookieBannerDetectedListener.uninit(); + }); + it("should set ._initialized to false and clear the triggerHandler and timestamps", () => { + assert.notOk(cookieBannerDetectedListener._initialized); + assert.equal(cookieBannerDetectedListener._triggerHandler, null); + }); + + it("should do nothing if already uninitialised", () => { + cookieBannerDetectedListener.uninit(); + assert.notOk(cookieBannerDetectedListener._initialized); + }); + + it("should remove event listeners from all existing browser windows", () => { + assert.called(existingWindow.removeEventListener); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js new file mode 100644 index 0000000000..a5748d59ce --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/CFRMessageProvider.test.js @@ -0,0 +1,32 @@ +import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs"; + +const REGULAR_IDS = [ + "FACEBOOK_CONTAINER", + "GOOGLE_TRANSLATE", + "YOUTUBE_ENHANCE", + // These are excluded for now. + // "WIKIPEDIA_CONTEXT_MENU_SEARCH", + // "REDDIT_ENHANCEMENT", +]; + +describe("CFRMessageProvider", () => { + let messages; + beforeEach(async () => { + messages = await CFRMessageProvider.getMessages(); + }); + it("should have a total of 11 messages", () => { + assert.lengthOf(messages, 11); + }); + it("should have one message each for the three regular addons", () => { + for (const id of REGULAR_IDS) { + const cohort3 = messages.find(msg => msg.id === `${id}_3`); + assert.ok(cohort3, `contains three day cohort for ${id}`); + assert.deepEqual( + cohort3.frequency, + { lifetime: 3 }, + "three day cohort has the right frequency cap" + ); + assert.notInclude(cohort3.targeting, `providerCohorts.cfr`); + } + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js new file mode 100644 index 0000000000..744b9f148c --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/CFRPageActions.test.js @@ -0,0 +1,1252 @@ +/* eslint max-nested-callbacks: ["error", 100] */ + +import { CFRPageActions, PageAction } from "lib/CFRPageActions.jsm"; +import { FAKE_RECOMMENDATION } from "./constants"; +import { GlobalOverrider } from "test/unit/utils"; +import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs"; + +describe("CFRPageActions", () => { + let sandbox; + let clock; + let fakeRecommendation; + let fakeHost; + let fakeBrowser; + let dispatchStub; + let globals; + let containerElem; + let elements; + let announceStub; + let fakeRemoteL10n; + + const elementIDs = [ + "urlbar", + "urlbar-input", + "contextual-feature-recommendation", + "cfr-button", + "cfr-label", + "contextual-feature-recommendation-notification", + "cfr-notification-header-label", + "cfr-notification-header-link", + "cfr-notification-header-image", + "cfr-notification-author", + "cfr-notification-footer", + "cfr-notification-footer-text", + "cfr-notification-footer-filled-stars", + "cfr-notification-footer-empty-stars", + "cfr-notification-footer-users", + "cfr-notification-footer-spacer", + "cfr-notification-footer-learn-more-link", + ]; + const elementClassNames = ["popup-notification-body-container"]; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sandbox.useFakeTimers(); + + announceStub = sandbox.stub(); + const A11yUtils = { announce: announceStub }; + fakeRecommendation = { ...FAKE_RECOMMENDATION }; + fakeHost = "mozilla.org"; + fakeBrowser = { + documentURI: { + scheme: "https", + host: fakeHost, + }, + ownerGlobal: window, + }; + dispatchStub = sandbox.stub(); + + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox.stub().returns(document.createElement("div")), + }; + + const gURLBar = document.createElement("div"); + gURLBar.textbox = document.createElement("div"); + + globals = new GlobalOverrider(); + globals.set({ + RemoteL10n: fakeRemoteL10n, + promiseDocumentFlushed: sandbox + .stub() + .callsFake(fn => Promise.resolve(fn())), + PopupNotifications: { + show: sandbox.stub(), + remove: sandbox.stub(), + }, + PrivateBrowsingUtils: { isWindowPrivate: sandbox.stub().returns(false) }, + gBrowser: { selectedBrowser: fakeBrowser }, + A11yUtils, + gURLBar, + }); + document.createXULElement = document.createElement; + + elements = {}; + const [body] = document.getElementsByTagName("body"); + containerElem = document.createElement("div"); + body.appendChild(containerElem); + for (const id of elementIDs) { + const elem = document.createElement("div"); + elem.setAttribute("id", id); + containerElem.appendChild(elem); + elements[id] = elem; + } + for (const className of elementClassNames) { + const elem = document.createElement("div"); + elem.setAttribute("class", className); + containerElem.appendChild(elem); + elements[className] = elem; + } + }); + + afterEach(() => { + CFRPageActions.clearRecommendations(); + containerElem.remove(); + sandbox.restore(); + globals.restore(); + }); + + describe("PageAction", () => { + let pageAction; + + beforeEach(() => { + pageAction = new PageAction(window, dispatchStub); + }); + + describe("#addImpression", () => { + it("should call _sendTelemetry with the impression payload", () => { + const recommendation = { + id: "foo", + content: { bucket_id: "bar" }, + }; + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction.addImpression(recommendation); + + assert.calledWith(pageAction._sendTelemetry, { + message_id: "foo", + bucket_id: "bar", + event: "IMPRESSION", + }); + }); + }); + + describe("#showAddressBarNotifier", () => { + it("should un-hideAddressBarNotifier the element and set the right label value", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.isFalse(pageAction.container.hidden); + assert.equal( + pageAction.label.value, + fakeRecommendation.content.notification_text + ); + }); + it("should wait for the document layout to flush", async () => { + sandbox.spy(pageAction.label, "getClientRects"); + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.calledOnce(global.promiseDocumentFlushed); + assert.callOrder( + global.promiseDocumentFlushed, + pageAction.label.getClientRects + ); + }); + it("should set the CSS variable --cfr-label-width correctly", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation); + const expectedWidth = pageAction.label.getClientRects()[0].width; + assert.equal( + pageAction.urlbarinput.style.getPropertyValue("--cfr-label-width"), + `${expectedWidth}px` + ); + }); + it("should cause an expansion, and dispatch an impression if `expand` is true", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + sandbox.spy(pageAction, "_expand"); + sandbox.spy(pageAction, "_dispatchImpression"); + + await pageAction.showAddressBarNotifier(fakeRecommendation); + assert.notCalled(pageAction._dispatchImpression); + clock.tick(1001); + assert.notEqual( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledOnce(pageAction._clearScheduledStateChanges); + clock.tick(1001); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + assert.calledOnce(pageAction._dispatchImpression); + assert.calledWith(pageAction._dispatchImpression, fakeRecommendation); + }); + it("should send telemetry if `expand` is true and the id and bucket_id are provided", async () => { + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + }); + + describe("#hideAddressBarNotifier", () => { + it("should hideAddressBarNotifier the container, cancel any state changes, and remove the state attribute", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction.hideAddressBarNotifier(); + assert.isTrue(pageAction.container.hidden); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + }); + it("should remove the `currentNotification`", () => { + const notification = {}; + pageAction.currentNotification = notification; + pageAction.hideAddressBarNotifier(); + assert.calledWith(global.PopupNotifications.remove, notification); + }); + }); + + describe("#_expand", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to 'expanded'", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._expand(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + it("with a delay, should set the expanded state after the correct amount of time", () => { + const delay = 1234; + pageAction._expand(delay); + // We expect that an expansion has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "expanded" + ); + }); + }); + + describe("#_collapse", () => { + beforeEach(() => { + pageAction._clearScheduledStateChanges(); + pageAction.urlbar.removeAttribute("cfr-recommendation-state"); + }); + it("without a delay, should clear other state changes and set the state to collapsed only if it's already expanded", () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + pageAction._collapse(); + assert.calledOnce(pageAction._clearScheduledStateChanges); + assert.isNull( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state") + ); + pageAction.urlbarinput.setAttribute( + "cfr-recommendation-state", + "expanded" + ); + pageAction._collapse(); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + it("with a delay, should set the collapsed state after the correct amount of time", () => { + const delay = 1234; + pageAction._collapse(delay); + clock.tick(delay + 1); + // The state was _not_ "expanded" and so should not have been set to "collapsed" + assert.isNull( + pageAction.urlbar.getAttribute("cfr-recommendation-state") + ); + + pageAction._expand(); + pageAction._collapse(delay); + // We expect that a collapse has been scheduled + assert.lengthOf(pageAction.stateTransitionTimeoutIDs, 1); + clock.tick(delay + 1); + // This time it was "expanded" so should now (after the delay) be "collapsed" + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + }); + }); + + describe("#_clearScheduledStateChanges", () => { + it("should call .clearTimeout on all stored timeoutIDs", () => { + pageAction.stateTransitionTimeoutIDs = [42, 73, 1997]; + sandbox.spy(pageAction.window, "clearTimeout"); + pageAction._clearScheduledStateChanges(); + assert.calledThrice(pageAction.window.clearTimeout); + assert.calledWith(pageAction.window.clearTimeout, 42); + assert.calledWith(pageAction.window.clearTimeout, 73); + assert.calledWith(pageAction.window.clearTimeout, 1997); + }); + }); + + describe("#_popupStateChange", () => { + it("should collapse the notification and send dismiss telemetry on 'dismissed'", () => { + pageAction._expand(); + + sandbox.spy(pageAction, "_sendTelemetry"); + + pageAction._popupStateChange("dismissed"); + assert.equal( + pageAction.urlbarinput.getAttribute("cfr-recommendation-state"), + "collapsed" + ); + + assert.equal( + pageAction._sendTelemetry.lastCall.args[0].event, + "DISMISS" + ); + }); + it("should remove the notification on 'removed'", () => { + pageAction._expand(); + const fakeNotification = {}; + + pageAction.currentNotification = fakeNotification; + pageAction._popupStateChange("removed"); + assert.calledOnce(global.PopupNotifications.remove); + assert.calledWith(global.PopupNotifications.remove, fakeNotification); + }); + it("should do nothing for other states", () => { + pageAction._popupStateChange("opened"); + assert.notCalled(global.PopupNotifications.remove); + }); + }); + + describe("#dispatchUserAction", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakeAction = {}; + pageAction.dispatchUserAction(fakeAction); + assert.calledOnce(dispatchStub); + assert.calledWith( + dispatchStub, + { type: "USER_ACTION", data: fakeAction }, + fakeBrowser + ); + }); + }); + + describe("#_dispatchImpression", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._dispatchImpression("fake impression"); + assert.calledWith(dispatchStub, { + type: "IMPRESSION", + data: "fake impression", + }); + }); + }); + + describe("#_sendTelemetry", () => { + it("should call ._dispatchCFRAction with the right action", () => { + const fakePing = { message_id: 42 }; + pageAction._sendTelemetry(fakePing); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: 42, + }, + }); + }); + }); + + describe("#_blockMessage", () => { + it("should call ._dispatchCFRAction with the right action", () => { + pageAction._blockMessage("fake id"); + assert.calledOnce(dispatchStub); + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: "fake id" }, + }); + }); + }); + + describe("#getStrings", () => { + let formatMessagesStub; + const localeStrings = [ + { + value: "你好世界", + attributes: [ + { name: "first_attr", value: 42 }, + { name: "second_attr", value: "some string" }, + { name: "third_attr", value: [1, 2, 3] }, + ], + }, + ]; + + beforeEach(() => { + formatMessagesStub = sandbox + .stub() + .withArgs({ id: "hello_world" }) + .resolves(localeStrings); + global.RemoteL10n.l10n.formatMessages = formatMessagesStub; + }); + + it("should return the argument if a string_id is not defined", async () => { + assert.deepEqual(await pageAction.getStrings({}), {}); + assert.equal(await pageAction.getStrings("some string"), "some string"); + }); + it("should get the right locale string", async () => { + assert.equal( + await pageAction.getStrings({ string_id: "hello_world" }), + localeStrings[0].value + ); + }); + it("should return the right sub-attribute if specified", async () => { + assert.equal( + await pageAction.getStrings( + { string_id: "hello_world" }, + "second_attr" + ), + "some string" + ); + }); + it("should attach attributes to string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson); + + assert.equal(result, fromJson.value); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes when doing string overrides", async () => { + const fromJson = { value: "Add Now", attributes: { accesskey: "A" } }; + + const result = await pageAction.getStrings(fromJson, "accesskey"); + + assert.equal(result, "A"); + }); + it("should resolve ftl strings and attach subAttributes", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl); + + assert.equal(result, "Add Now"); + assert.propertyVal(result.attributes, "accesskey", "A"); + }); + it("should return subAttributes from ftl ids", async () => { + const fromFtl = { string_id: "cfr-doorhanger-extension-ok-button" }; + formatMessagesStub.resolves([ + { value: "Add Now", attributes: [{ name: "accesskey", value: "A" }] }, + ]); + + const result = await pageAction.getStrings(fromFtl, "accesskey"); + + assert.equal(result, "A"); + }); + it("should report an error when no attributes are present but subAttribute is requested", async () => { + const fromJson = { value: "Foo" }; + const stub = sandbox.stub(global.console, "error"); + + await pageAction.getStrings(fromJson, "accesskey"); + + assert.calledOnce(stub); + stub.restore(); + }); + }); + + describe("#_cfrUrlbarButtonClick", () => { + let translateElementsStub; + let setAttributesStub; + let getStringsStub; + beforeEach(async () => { + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + getStringsStub = sandbox.stub(pageAction, "getStrings").resolves(""); + getStringsStub + .callsFake(async a => a) // eslint-disable-line max-nested-callbacks + .withArgs({ string_id: "primary_button_id" }) + .resolves({ value: "Primary Button", attributes: { accesskey: "p" } }) + .withArgs({ string_id: "secondary_button_id" }) + .resolves({ + value: "Secondary Button", + attributes: { accesskey: "s" }, + }) + .withArgs({ string_id: "secondary_button_id_2" }) + .resolves({ + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }) + .withArgs({ string_id: "secondary_button_id_3" }) + .resolves({ + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }) + .withArgs( + sinon.match({ + string_id: "cfr-doorhanger-extension-learn-more-link", + }) + ) + .resolves("Learn more") + .withArgs( + sinon.match({ string_id: "cfr-doorhanger-extension-total-users" }) + ) + .callsFake(async ({ args }) => `${args.total} users`); // eslint-disable-line max-nested-callbacks + + translateElementsStub = sandbox.stub().resolves(); + setAttributesStub = sandbox.stub(); + global.RemoteL10n.l10n.setAttributes = setAttributesStub; + global.RemoteL10n.l10n.translateElements = translateElementsStub; + }); + + it("should call `.hideAddressBarNotifier` and do nothing if there is no recommendation for the selected browser", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.delete(fakeBrowser); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.notCalled(global.PopupNotifications.show); + }); + it("should cancel any planned state changes", async () => { + sandbox.spy(pageAction, "_clearScheduledStateChanges"); + assert.notCalled(pageAction._clearScheduledStateChanges); + await pageAction._cfrUrlbarButtonClick({}); + assert.calledOnce(pageAction._clearScheduledStateChanges); + }); + it("should set the right text values", async () => { + await pageAction._cfrUrlbarButtonClick({}); + const headerLabel = elements["cfr-notification-header-label"]; + const headerLink = elements["cfr-notification-header-link"]; + const headerImage = elements["cfr-notification-header-image"]; + const footerLink = elements["cfr-notification-footer-learn-more-link"]; + assert.equal( + headerLabel.value, + fakeRecommendation.content.heading_text + ); + assert.isTrue( + headerLink + .getAttribute("href") + .endsWith(fakeRecommendation.content.info_icon.sumo_path) + ); + assert.equal( + headerImage.getAttribute("tooltiptext"), + fakeRecommendation.content.info_icon.label + ); + const htmlFooterEl = fakeRemoteL10n.createElement.args.find( + /* eslint-disable-next-line max-nested-callbacks */ + ([doc, el, args]) => + args && args.content === fakeRecommendation.content.text + ); + assert.ok(htmlFooterEl); + assert.equal(footerLink.value, "Learn more"); + assert.equal( + footerLink.getAttribute("href"), + fakeRecommendation.content.addon.amo_url + ); + }); + it("should add the rating correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerFilledStars = + elements["cfr-notification-footer-filled-stars"]; + const footerEmptyStars = + elements["cfr-notification-footer-empty-stars"]; + // .toFixed to sort out some floating precision errors + assert.equal( + footerFilledStars.style.width, + `${(4.2 * 16).toFixed(1)}px` + ); + assert.equal( + footerEmptyStars.style.width, + `${(0.8 * 16).toFixed(1)}px` + ); + }); + it("should add the number of users correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + const footerUsers = elements["cfr-notification-footer-users"]; + assert.isNull(footerUsers.getAttribute("hidden")); + assert.equal( + footerUsers.getAttribute("value"), + `${fakeRecommendation.content.addon.users}` + ); + }); + it("should send the right telemetry", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should set the main action correctly", async () => { + sinon + .stub(CFRPageActions, "_fetchLatestAddonVersion") + .resolves("latest-addon.xpi"); + await pageAction._cfrUrlbarButtonClick(); + const mainAction = global.PopupNotifications.show.firstCall.args[4]; // eslint-disable-line prefer-destructuring + assert.deepEqual(mainAction.label, { + value: "Primary Button", + attributes: { accesskey: "p" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + await mainAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + // Should block the message + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: fakeRecommendation.id }, + }); + // Should trigger the action + assert.calledWith( + dispatchStub, + { + type: "USER_ACTION", + data: { id: "primary_action", data: { url: "latest-addon.xpi" } }, + }, + fakeBrowser + ); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "INSTALL", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should set the secondary action correctly", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const [secondaryAction] = + global.PopupNotifications.show.firstCall.args[5]; + + assert.deepEqual(secondaryAction.label, { + value: "Secondary Button", + attributes: { accesskey: "s" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + secondaryAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "DISMISS", + }, + }); + // Don't remove the recommendation on `DISMISS` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should send right telemetry for BLOCK secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const blockAction = global.PopupNotifications.show.firstCall.args[5][1]; + + assert.deepEqual(blockAction.label, { + value: "Secondary Button 2", + attributes: { accesskey: "a" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + sandbox.spy(pageAction, "_blockMessage"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + blockAction.callback(); + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.calledOnce(pageAction._blockMessage); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "BLOCK", + }, + }); + // Should remove the recommendation + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should send right telemetry for MANAGE secondary action", async () => { + await pageAction._cfrUrlbarButtonClick(); + // eslint-disable-next-line prefer-destructuring + const manageAction = + global.PopupNotifications.show.firstCall.args[5][2]; + + assert.deepEqual(manageAction.label, { + value: "Secondary Button 3", + attributes: { accesskey: "g" }, + }); + sandbox.spy(pageAction, "hideAddressBarNotifier"); + CFRPageActions.RecommendationMap.set(fakeBrowser, {}); + manageAction.callback(); + // Should send telemetry + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "MANAGE", + }, + }); + // Don't remove the recommendation on `MANAGE` action + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + assert.notCalled(pageAction.hideAddressBarNotifier); + }); + it("should call PopupNotifications.show with the right arguments", async () => { + await pageAction._cfrUrlbarButtonClick(); + assert.calledWith( + global.PopupNotifications.show, + fakeBrowser, + "contextual-feature-recommendation", + fakeRecommendation.content.addon.title, + "cfr", + sinon.match.any, // Corresponds to the main action, tested above + sinon.match.any, // Corresponds to the secondary action, tested above + { + popupIconURL: fakeRecommendation.content.addon.icon, + hideClose: true, + eventCallback: pageAction._popupStateChange, + persistent: false, + persistWhileVisible: false, + popupIconClass: fakeRecommendation.content.icon_class, + recordTelemetryInPrivateBrowsing: + fakeRecommendation.content.show_in_private_browsing, + name: { + string_id: "cfr-doorhanger-extension-author", + args: { name: fakeRecommendation.content.addon.author }, + }, + } + ); + }); + }); + describe("#_cfrUrlbarButtonClick/cfr_urlbar_chiclet", () => { + let heartbeatRecommendation; + beforeEach(async () => { + heartbeatRecommendation = (await CFRMessageProvider.getMessages()).find( + m => m.template === "cfr_urlbar_chiclet" + ); + CFRPageActions.PageActionMap.set(fakeBrowser.ownerGlobal, pageAction); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + heartbeatRecommendation, + dispatchStub + ); + }); + it("should dispatch a click event", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + message_id: heartbeatRecommendation.id, + bucket_id: heartbeatRecommendation.content.bucket_id, + event: "CLICK_DOORHANGER", + }, + }); + }); + it("should dispatch a USER_ACTION for chiclet_open_url layout", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "USER_ACTION", + data: { + data: { + args: heartbeatRecommendation.content.action.url, + where: heartbeatRecommendation.content.action.where, + }, + type: "OPEN_URL", + }, + }); + }); + it("should block the message after the click", async () => { + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledWith(dispatchStub, { + type: "BLOCK_MESSAGE_BY_ID", + data: { id: heartbeatRecommendation.id }, + }); + }); + it("should remove the button and browser entry", async () => { + sandbox.spy(pageAction, "hideAddressBarNotifier"); + + await pageAction._cfrUrlbarButtonClick({}); + + assert.calledOnce(pageAction.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + }); + + describe("CFRPageActions", () => { + beforeEach(() => { + // Spy on the prototype methods to inspect calls for any PageAction instance + sandbox.spy(PageAction.prototype, "showAddressBarNotifier"); + sandbox.spy(PageAction.prototype, "hideAddressBarNotifier"); + }); + + describe("updatePageActions", () => { + let savedRec; + + beforeEach(() => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.set( + win, + new PageAction(win, dispatchStub) + ); + const { id, content } = fakeRecommendation; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + }); + + it("should do nothing if a pageAction doesn't exist for the window", () => { + const win = fakeBrowser.ownerGlobal; + CFRPageActions.PageActionMap.delete(win); + CFRPageActions.updatePageActions(fakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should do nothing if the browser is not the `selectedBrowser`", () => { + const someOtherFakeBrowser = {}; + CFRPageActions.updatePageActions(someOtherFakeBrowser); + assert.notCalled(PageAction.prototype.showAddressBarNotifier); + assert.notCalled(PageAction.prototype.hideAddressBarNotifier); + }); + it("should hideAddressBarNotifier the pageAction if a recommendation doesn't exist for the given browser", () => { + CFRPageActions.RecommendationMap.delete(fakeBrowser); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + }); + it("should show the pageAction if a recommendation exists and the host matches", () => { + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + savedRec + ); + }); + it("should show the pageAction if a recommendation exists and it doesn't have a host defined", () => { + const recNoHost = { ...savedRec, host: undefined }; + CFRPageActions.RecommendationMap.set(fakeBrowser, recNoHost); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + assert.calledWith( + PageAction.prototype.showAddressBarNotifier, + recNoHost + ); + }); + it("should hideAddressBarNotifier the pageAction and delete the recommendation if the recommendation exists but the host doesn't match", () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + fakeBrowser.documentURI.host = someOtherFakeHost; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + CFRPageActions.updatePageActions(fakeBrowser); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should not call `delete` if retain is true", () => { + savedRec.retain = true; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should call `delete` if retain is false", () => { + savedRec.retain = false; + fakeBrowser.documentURI.host = "subdomain.mozilla.com"; + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + CFRPageActions.updatePageActions(fakeBrowser); + assert.propertyVal(savedRec, "retain", false); + assert.calledOnce(PageAction.prototype.hideAddressBarNotifier); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + }); + + describe("forceRecommendation", () => { + it("should succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.forceRecommendation( + fakeBrowser, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + }); + + describe("showPopup", () => { + let savedRec; + let pageAction; + let fakeAnchorId = "fake_anchor_id"; + let sandboxShowPopup = sinon.createSandbox(); + let fakePopUp = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + heading_text: "Fake Heading Text", + anchor_id: "fake_anchor_id", + }, + }; + beforeEach(() => { + const { id, content } = fakePopUp; + savedRec = { + id, + host: fakeHost, + content, + }; + CFRPageActions.RecommendationMap.set(fakeBrowser, savedRec); + pageAction = new PageAction(window, dispatchStub); + + sandboxShowPopup.stub(window.document, "getElementById"); + sandboxShowPopup.stub(pageAction, "_renderPopup"); + globals.set({ + CustomizableUI: { + getWidget: sandboxShowPopup + .stub() + .withArgs(fakeAnchorId) + .returns({ areaType: "menu-panel" }), + }, + }); + }); + afterEach(() => { + sandboxShowPopup.restore(); + globals.restore(); + }); + + it("Should use default anchor_id if an alternate hasn't been provided", async () => { + await pageAction.showPopup(); + + assert.calledWith(window.document.getElementById, fakeAnchorId); + }); + + it("Should use alt_anchor_if if one has been provided AND the anchor_id has been removed", async () => { + let fakeAltAnchorId = "fake_alt_anchor_id"; + + fakePopUp.content.alt_anchor_id = fakeAltAnchorId; + await pageAction.showPopup(); + assert.calledWith(window.document.getElementById, fakeAltAnchorId); + }); + }); + + describe("addRecommendation", () => { + it("should fail and not add a recommendation if the browser is part of a private window", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should successfully add a private browsing recommendation and send correct telemetry", async () => { + global.PrivateBrowsingUtils.isWindowPrivate.returns(true); + fakeRecommendation.content.show_in_private_browsing = true; + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isTrue(CFRPageActions.RecommendationMap.has(fakeBrowser)); + + const pageAction = CFRPageActions.PageActionMap.get( + fakeBrowser.ownerGlobal + ); + await pageAction.showAddressBarNotifier(fakeRecommendation, true); + assert.calledWith(dispatchStub, { + type: "DOORHANGER_TELEMETRY", + data: { + action: "cfr_user_event", + source: "CFR", + is_private: true, + message_id: fakeRecommendation.id, + bucket_id: fakeRecommendation.content.bucket_id, + event: "IMPRESSION", + }, + }); + }); + it("should fail and not add a recommendation if the browser is not the selected browser", async () => { + global.gBrowser.selectedBrowser = {}; // Some other browser + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should fail and not add a recommendation if the browser does not exist", async () => { + assert.isFalse( + await CFRPageActions.addRecommendation( + undefined, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.isFalse(CFRPageActions.RecommendationMap.has(fakeBrowser)); + }); + it("should fail and not add a recommendation if the host doesn't match", async () => { + const someOtherFakeHost = "subdomain.mozilla.com"; + assert.isFalse( + await CFRPageActions.addRecommendation( + fakeBrowser, + someOtherFakeHost, + fakeRecommendation, + dispatchStub + ) + ); + }); + it("should otherwise succeed and add an element to the RecommendationMap", async () => { + assert.isTrue( + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ) + ); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should create a PageAction if one doesn't exist for the window, save it in the PageActionMap, and call `show`", async () => { + const win = fakeBrowser.ownerGlobal; + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const pageAction = CFRPageActions.PageActionMap.get(win); + assert.equal(win, pageAction.window); + assert.equal(dispatchStub, pageAction._dispatchCFRAction); + assert.calledOnce(PageAction.prototype.showAddressBarNotifier); + }); + it("should add the right url if we fetched and addon install URL", async () => { + fakeRecommendation.template = "cfr_doorhanger"; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + const recommendation = + CFRPageActions.RecommendationMap.get(fakeBrowser); + + // sanity check - just go through some of the rest of the attributes to make sure they were untouched + assert.equal(recommendation.id, fakeRecommendation.id); + assert.equal( + recommendation.content.heading_text, + fakeRecommendation.content.heading_text + ); + assert.equal( + recommendation.content.addon, + fakeRecommendation.content.addon + ); + assert.equal( + recommendation.content.text, + fakeRecommendation.content.text + ); + assert.equal( + recommendation.content.buttons.secondary, + fakeRecommendation.content.buttons.secondary + ); + assert.equal( + recommendation.content.buttons.primary.action.id, + fakeRecommendation.content.buttons.primary.action.id + ); + + delete fakeRecommendation.template; + }); + it("should prevent a second message if one is currently displayed", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + let messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + + assert.isTrue(messageAdded); + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + + messageAdded = await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + // Adding failed + assert.isFalse(messageAdded); + // First message is still there + assert.deepInclude(CFRPageActions.RecommendationMap.get(fakeBrowser), { + id: fakeRecommendation.id, + host: fakeHost, + content: fakeRecommendation.content, + }); + }); + it("should send impressions just for the first message", async () => { + const secondMessage = { ...fakeRecommendation, id: "second_message" }; + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + fakeRecommendation, + dispatchStub + ); + await CFRPageActions.addRecommendation( + fakeBrowser, + fakeHost, + secondMessage, + dispatchStub + ); + + // Doorhanger telemetry + Impression for just 1 message + assert.calledTwice(dispatchStub); + const [firstArgs] = dispatchStub.firstCall.args; + const [secondArgs] = dispatchStub.secondCall.args; + assert.equal(firstArgs.data.id, secondArgs.data.message_id); + }); + }); + + describe("clearRecommendations", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + const browsers = [{}, {}, {}, {}]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + for (const browser of browsers) { + CFRPageActions.RecommendationMap.set(browser, {}); + } + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should hideAddressBarNotifier the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.clearRecommendations(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].hideAddressBarNotifier); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].hideAddressBarNotifier); + }); + it("should clear the PageActionMap and the RecommendationMap", () => { + CFRPageActions.clearRecommendations(); + + // Both are WeakMaps and so are not iterable, cannot be cleared, and + // cannot have their length queried directly, so we have to check + // whether previous elements still exist + assert.lengthOf(windows, 3); + for (const win of windows) { + assert.isFalse(CFRPageActions.PageActionMap.has(win)); + } + assert.lengthOf(browsers, 4); + for (const browser of browsers) { + assert.isFalse(CFRPageActions.RecommendationMap.has(browser)); + } + }); + }); + + describe("reloadL10n", () => { + const createFakePageAction = () => ({ + hideAddressBarNotifier() {}, + reloadL10n: sandbox.stub(), + }); + const windows = [{}, {}, { closed: true }]; + + beforeEach(() => { + CFRPageActions.PageActionMap.set(windows[0], createFakePageAction()); + CFRPageActions.PageActionMap.set(windows[2], createFakePageAction()); + globals.set({ Services: { wm: { getEnumerator: () => windows } } }); + }); + + it("should call reloadL10n for all the PageActions of any existing, non-closed windows", () => { + const pageActions = windows.map(win => + CFRPageActions.PageActionMap.get(win) + ); + CFRPageActions.reloadL10n(); + + // Only the first window had a PageAction and wasn't closed + assert.calledOnce(pageActions[0].reloadL10n); + assert.isUndefined(pageActions[1]); + assert.notCalled(pageActions[2].reloadL10n); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js new file mode 100644 index 0000000000..d855f89d27 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/MessageLoaderUtils.test.js @@ -0,0 +1,459 @@ +import { MessageLoaderUtils } from "lib/ASRouter.jsm"; +const { STARTPAGE_VERSION } = MessageLoaderUtils; + +const FAKE_OPTIONS = { + storage: { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve(); + }, + }, + dispatchToAS: () => {}, +}; +const FAKE_RESPONSE_HEADERS = { get() {} }; + +describe("MessageLoaderUtils", () => { + let fetchStub; + let clock; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clock = sinon.useFakeTimers(); + fetchStub = sinon.stub(global, "fetch"); + }); + afterEach(() => { + sandbox.restore(); + clock.restore(); + fetchStub.restore(); + }); + + describe("#loadMessagesForProvider", () => { + it("should return messages for a local provider with hardcoded messages", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + }); + it("should filter out local messages listed in the `exclude` field", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "local", + messages: [sourceMessage], + exclude: ["foo"], + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.lengthOf(result.messages, 0); + }); + it("should return messages for remote provider", async () => { + const sourceMessage = { id: "foo" }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({ messages: [sourceMessage] }), + headers: FAKE_RESPONSE_HEADERS, + }); + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.isArray(result.messages); + // Does the message have the right properties? + const [message] = result.messages; + assert.propertyVal(message, "id", "foo"); + assert.propertyVal(message, "provider", "provider123"); + assert.propertyVal(message, "provider_url", "https://foo.com"); + }); + describe("remote provider HTTP codes", () => { + const testMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + const respJson = { messages: [testMessage] }; + + function assertReturnsCorrectMessages(actual) { + assert.isArray(actual.messages); + // Does the message have the right properties? + const [message] = actual.messages; + assert.propertyVal(message, "id", testMessage.id); + assert.propertyVal(message, "provider", provider.id); + assert.propertyVal(message, "provider_url", provider.url); + } + + it("should return messages for 200 response", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return messages for a 302 response with json", async () => { + fetchStub.resolves({ + ok: true, + status: 302, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + assertReturnsCorrectMessages( + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ) + ); + }); + + it("should return an empty array for a 204 response", async () => { + fetchStub.resolves({ + ok: true, + status: 204, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return an empty array for a 500 response", async () => { + fetchStub.resolves({ + ok: false, + status: 500, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should return cached messages for a 304 response", async () => { + clock.tick(302); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 304, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return an empty array if json doesn't parse properly", async () => { + fetchStub.resolves({ + ok: false, + status: 200, + json: () => "", + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + + it("should report response parsing errors with MessageLoaderUtils.reportError", async () => { + const err = {}; + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().rejects(err), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report that json parsing failed + assert.calledWith(MessageLoaderUtils.reportError, err); + }); + + it("should report missing `messages` with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: true, + status: 200, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "No messages returned from https://foo.com." + ); + }); + + it("should report bad status responses with MessageLoaderUtils.reportError", async () => { + sandbox.spy(MessageLoaderUtils, "reportError"); + fetchStub.resolves({ + ok: false, + status: 500, + json: sandbox.stub().resolves({}), + headers: FAKE_RESPONSE_HEADERS, + }); + await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.calledOnce(MessageLoaderUtils.reportError); + // Report no messages returned + assert.calledWith( + MessageLoaderUtils.reportError, + "Invalid response status 500 from https://foo.com." + ); + }); + + it("should return an empty array if the request rejects", async () => { + fetchStub.rejects(new Error("something went wrong")); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + assert.deepEqual(result.messages, []); + }); + }); + describe("remote provider caching", () => { + const provider = { + id: "provider123", + type: "remote", + url: "https://foo.com", + updateCycleInMs: 300, + }; + + it("should return cached results if they aren't expired", async () => { + clock.tick(1); + const messages = [{ id: "message-1" }, { id: "message-2" }]; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages, + etag: "etag0987654321", + lastFetched: Date.now(), + }, + }); + }, + }; + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, messages.length); + messages.forEach(message => { + assert.ok(result.messages.find(m => m.id === message.id)); + }); + }); + + it("should return fetch results if the cache messages are expired", async () => { + clock.tick(302); + const testMessage = { id: "foo" }; + const respJson = { messages: [testMessage] }; + const fakeStorage = { + set() { + return Promise.resolve(); + }, + get() { + return Promise.resolve({ + [provider.id]: { + version: STARTPAGE_VERSION, + url: provider.url, + messages: [{ id: "message-1" }, { id: "message-2" }], + etag: "etag0987654321", + lastFetched: 1, + }, + }); + }, + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(respJson), + headers: FAKE_RESPONSE_HEADERS, + }); + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + { ...FAKE_OPTIONS, storage: fakeStorage } + ); + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].id, testMessage.id); + }); + }); + it("should return an empty array for a remote provider with a blank URL without attempting a request", async () => { + const provider = { id: "provider123", type: "remote", url: "" }; + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.notCalled(fetchStub); + assert.deepEqual(result.messages, []); + }); + it("should return .lastUpdated with the time at which the messages were fetched", async () => { + const sourceMessage = { id: "foo" }; + const provider = { + id: "provider123", + type: "remote", + url: "foo.com", + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + new Promise(resolve => { + clock.tick(42); + resolve({ messages: [sourceMessage] }); + }), + headers: FAKE_RESPONSE_HEADERS, + }); + + const result = await MessageLoaderUtils.loadMessagesForProvider( + provider, + FAKE_OPTIONS + ); + + assert.propertyVal(result, "lastUpdated", 42); + }); + }); + + describe("#shouldProviderUpdate", () => { + it("should return true if the provider does not had a .lastUpdated property", () => { + assert.isTrue(MessageLoaderUtils.shouldProviderUpdate({ id: "foo" })); + }); + it("should return false if the provider does not had a .updateCycleInMs property and has a .lastUpdated", () => { + clock.tick(1); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ id: "foo", lastUpdated: 0 }) + ); + }); + it("should return true if the time since .lastUpdated is greater than .updateCycleInMs", () => { + clock.tick(301); + assert.isTrue( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + it("should return false if the time since .lastUpdated is less than .updateCycleInMs", () => { + clock.tick(299); + assert.isFalse( + MessageLoaderUtils.shouldProviderUpdate({ + id: "foo", + lastUpdated: 0, + updateCycleInMs: 300, + }) + ); + }); + }); + + describe("#cleanupCache", () => { + it("should remove data for providers no longer active", async () => { + const fakeStorage = { + get: sinon.stub().returns( + Promise.resolve({ + "id-1": {}, + "id-2": {}, + "id-3": {}, + }) + ), + set: sinon.stub().returns(Promise.resolve()), + }; + const fakeProviders = [ + { id: "id-1", type: "remote" }, + { id: "id-3", type: "remote" }, + ]; + + await MessageLoaderUtils.cleanupCache(fakeProviders, fakeStorage); + + assert.calledOnce(fakeStorage.set); + assert.calledWith( + fakeStorage.set, + MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, + { "id-1": {}, "id-3": {} } + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx new file mode 100644 index 0000000000..889d26a9d3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/ModalOverlay.test.jsx @@ -0,0 +1,69 @@ +import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay"; +import { mount } from "enzyme"; +import React from "react"; + +describe("ModalOverlayWrapper", () => { + let fakeDoc; + let sandbox; + let header; + beforeEach(() => { + sandbox = sinon.createSandbox(); + header = document.createElement("div"); + + fakeDoc = { + addEventListener: sandbox.stub(), + removeEventListener: sandbox.stub(), + body: { classList: { add: sandbox.stub(), remove: sandbox.stub() } }, + getElementById() { + return header; + }, + }; + }); + afterEach(() => { + sandbox.restore(); + }); + it("should add eventListener and a class on mount", async () => { + mount(<ModalOverlayWrapper document={fakeDoc} />); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledWith(fakeDoc.body.classList.add, "modal-open"); + }); + + it("should remove eventListener on unmount", async () => { + const wrapper = mount(<ModalOverlayWrapper document={fakeDoc} />); + wrapper.unmount(); + assert.calledOnce(fakeDoc.addEventListener); + assert.calledOnce(fakeDoc.removeEventListener); + assert.calledWith(fakeDoc.body.classList.remove, "modal-open"); + }); + + it("should call props.onClose on an Escape key", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Escape" }); + + assert.calledOnce(onClose); + }); + + it("should not call props.onClose on other keys than Escape", async () => { + const onClose = sandbox.stub(); + mount(<ModalOverlayWrapper document={fakeDoc} onClose={onClose} />); + + // Simulate onkeydown being called + const [, callback] = fakeDoc.addEventListener.firstCall.args; + callback({ key: "Ctrl" }); + + assert.notCalled(onClose); + }); + + it("should not call props.onClose when clicked outside dialog", async () => { + const onClose = sandbox.stub(); + const wrapper = mount( + <ModalOverlayWrapper document={fakeDoc} onClose={onClose} /> + ); + wrapper.find("div.modalOverlayOuter.active").simulate("click"); + assert.notCalled(onClose); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js new file mode 100644 index 0000000000..34adfc88f1 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/RemoteL10n.test.js @@ -0,0 +1,217 @@ +import { RemoteL10n, _RemoteL10n } from "lib/RemoteL10n.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("RemoteL10n", () => { + let sandbox; + let globals; + let domL10nStub; + let l10nRegStub; + let l10nRegInstance; + let fileSourceStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + domL10nStub = sandbox.stub(); + l10nRegInstance = { + hasSource: sandbox.stub(), + registerSources: sandbox.stub(), + removeSources: sandbox.stub(), + }; + + fileSourceStub = sandbox.stub(); + l10nRegStub = { + getInstance: () => { + return l10nRegInstance; + }, + }; + globals.set("DOMLocalization", domL10nStub); + globals.set("L10nRegistry", l10nRegStub); + globals.set("L10nFileSource", fileSourceStub); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + describe("#RemoteL10n", () => { + it("should create a new instance", () => { + assert.ok(new _RemoteL10n()); + }); + it("should create a DOMLocalization instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.propertyVal(instance._createDOML10n(), "instance", true); + assert.calledOnce(domL10nStub); + }); + it("should create a new instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + + instance.reloadL10n(); + + assert.ok(instance.l10n); + + assert.calledTwice(domL10nStub); + }); + it("should reuse the instance", () => { + domL10nStub.returns({ instance: true }); + const instance = new _RemoteL10n(); + + assert.ok(instance.l10n); + assert.ok(instance.l10n); + + assert.calledOnce(domL10nStub); + }); + }); + describe("#_createDOML10n", () => { + it("should load the remote Fluent file if USE_REMOTE_L10N_PREF is true", async () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + l10nRegInstance.hasSource.returns(false); + RemoteL10n._createDOML10n(); + + assert.calledOnce(domL10nStub); + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is the bundle generator. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.calledOnce(l10nRegInstance.registerSources); + assert.notCalled(l10nRegInstance.removeSources); + }); + it("should load the local Fluent file if USE_REMOTE_L10N_PREF is false", () => { + sandbox.stub(global.Services.prefs, "getBoolPref").returns(false); + l10nRegInstance.hasSource.returns(true); + RemoteL10n._createDOML10n(); + + const { args } = domL10nStub.firstCall; + // The first arg is the resource array, + // the second one is false (use async), + // and the third one is null. + assert.equal(args.length, 2); + assert.deepEqual(args[0], [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); + assert.isFalse(args[1]); + assert.calledOnce(l10nRegInstance.hasSource); + assert.notCalled(l10nRegInstance.registerSources); + assert.calledOnce(l10nRegInstance.removeSources); + }); + }); + describe("#createElement", () => { + let doc; + let instance; + let setStringStub; + let elem; + beforeEach(() => { + elem = document.createElement("div"); + doc = { + createElement: sandbox.stub().returns(elem), + createElementNS: sandbox.stub().returns(elem), + }; + instance = new _RemoteL10n(); + setStringStub = sandbox.stub(instance, "setString"); + }); + it("should call createElement if string_id is defined", () => { + instance.createElement(doc, "span", { content: { string_id: "foo" } }); + + assert.calledOnce(doc.createElement); + }); + it("should call createElementNS if string_id is not present", () => { + instance.createElement(doc, "span", { content: "foo" }); + + assert.calledOnce(doc.createElementNS); + }); + it("should set classList", () => { + instance.createElement(doc, "span", { classList: "foo" }); + + assert.isTrue(elem.classList.contains("foo")); + }); + it("should call setString", () => { + const options = { classList: "foo" }; + instance.createElement(doc, "span", options); + + assert.calledOnce(setStringStub); + assert.calledWithExactly(setStringStub, elem, options); + }); + }); + describe("#setString", () => { + let instance; + beforeEach(() => { + instance = new _RemoteL10n(); + }); + it("should set fluent variables and id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { + content: { string_id: "foo" }, + attributes: { bar: "bar", baz: "baz" }, + }); + + assert.calledThrice(el.setAttribute); + assert.calledWithExactly(el.setAttribute, "fluent-variable-bar", "bar"); + assert.calledWithExactly(el.setAttribute, "fluent-variable-baz", "baz"); + assert.calledWithExactly(el.setAttribute, "fluent-remote-id", "foo"); + }); + it("should set content if no string_id", () => { + let el = { setAttribute: sandbox.stub() }; + instance.setString(el, { content: "foo" }); + + assert.notCalled(el.setAttribute); + assert.equal(el.textContent, "foo"); + }); + }); + describe("#isLocaleSupported", () => { + it("should return true if the locale is en-US", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-US")); + }); + it("should return true if the locale is in all-locales", () => { + assert.ok(RemoteL10n.isLocaleSupported("en-CA")); + }); + it("should return false if the locale is not in all-locales", () => { + assert.ok(!RemoteL10n.isLocaleSupported("und")); + }); + }); + describe("#formatLocalizableText", () => { + let instance; + let formatValueStub; + beforeEach(() => { + instance = new _RemoteL10n(); + formatValueStub = sandbox.stub(); + sandbox + .stub(instance, "l10n") + .get(() => ({ formatValue: formatValueStub })); + }); + it("should localize a string_id", async () => { + formatValueStub.resolves("VALUE"); + + assert.equal( + await instance.formatLocalizableText({ string_id: "ID" }), + "VALUE" + ); + assert.calledOnce(formatValueStub); + }); + it("should pass through a string", async () => { + formatValueStub.reset(); + + assert.equal( + await instance.formatLocalizableText("unchanged"), + "unchanged" + ); + assert.isFalse(formatValueStub.called); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/RichText.test.jsx b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx new file mode 100644 index 0000000000..07c2a4d4be --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/RichText.test.jsx @@ -0,0 +1,101 @@ +import { + convertLinks, + RichText, +} from "content-src/asrouter/components/RichText/RichText"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { + Localized, + LocalizationProvider, + ReactLocalization, +} from "@fluent/react"; +import { mount } from "enzyme"; +import React from "react"; + +function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; +} + +describe("convertLinks", () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should return an object with anchor elements", () => { + const cta = { + url: "https://foo.com", + metric: "foo", + }; + const stub = sandbox.stub(); + const result = convertLinks({ cta }, stub); + + assert.property(result, "cta"); + assert.propertyVal(result.cta, "type", "a"); + assert.propertyVal(result.cta.props, "href", cta.url); + assert.propertyVal(result.cta.props, "data-metric", cta.metric); + assert.propertyVal(result.cta.props, "onClick", stub); + }); + it("should return an anchor element without href", () => { + const cta = { + url: "https://foo.com", + metric: "foo", + action: "OPEN_MENU", + args: "appMenu", + entrypoint_name: "entrypoint_name", + entrypoint_value: "entrypoint_value", + }; + const stub = sandbox.stub(); + const result = convertLinks({ cta }, stub); + + assert.property(result, "cta"); + assert.propertyVal(result.cta, "type", "a"); + assert.propertyVal(result.cta.props, "href", false); + assert.propertyVal(result.cta.props, "data-metric", cta.metric); + assert.propertyVal(result.cta.props, "data-action", cta.action); + assert.propertyVal(result.cta.props, "data-args", cta.args); + assert.propertyVal( + result.cta.props, + "data-entrypoint_name", + cta.entrypoint_name + ); + assert.propertyVal( + result.cta.props, + "data-entrypoint_value", + cta.entrypoint_value + ); + assert.propertyVal(result.cta.props, "onClick", stub); + }); + it("should follow openNewWindow prop", () => { + const cta = { url: "https://foo.com" }; + const newWindow = convertLinks({ cta }, sandbox.stub(), false, true); + const sameWindow = convertLinks({ cta }, sandbox.stub(), false); + + assert.propertyVal(newWindow.cta.props, "target", "_blank"); + assert.propertyVal(sameWindow.cta.props, "target", ""); + }); + it("should allow for custom elements & styles", () => { + const wrapper = mount( + <RichText + customElements={{ em: <em style={{ color: "#f05" }} /> }} + text="<em>foo</em>" + localization_id="text" + />, + mockL10nWrapper({ text: "<em>foo</em>" }) + ); + + const localized = wrapper.find(Localized); + assert.propertyVal(localized.props().elems.em.props.style, "color", "#f05"); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js new file mode 100644 index 0000000000..fc8fbe15ac --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/SnippetsTestMessageProvider.test.js @@ -0,0 +1,43 @@ +import EOYSnippetSchema from "../../../content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; +import SimpleBelowSearchSnippetSchema from "../../../content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json"; +import SimpleSnippetSchema from "../../../content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "../../../lib/SnippetsTestMessageProvider.sys.mjs"; +import SubmitFormSnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; +import SubmitFormScene2SnippetSchema from "../../../content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json"; + +const schemas = { + simple_snippet: SimpleSnippetSchema, + newsletter_snippet: SubmitFormSnippetSchema, + fxa_signup_snippet: SubmitFormSnippetSchema, + send_to_device_snippet: SubmitFormSnippetSchema, + send_to_device_scene2_snippet: SubmitFormScene2SnippetSchema, + eoy_snippet: EOYSnippetSchema, + simple_below_search_snippet: SimpleBelowSearchSnippetSchema, +}; + +describe("SnippetsTestMessageProvider", async () => { + let messages = await SnippetsTestMessageProvider.getMessages(); + + it("should return an array of messages", () => { + assert.isArray(messages); + }); + + it("should have a valid example of each schema", () => { + Object.keys(schemas).forEach(templateName => { + const example = messages.find( + message => message.template === templateName + ); + assert.ok(example, `has a ${templateName} example`); + }); + }); + + it("should have examples that are valid", () => { + messages.forEach(example => { + assert.jsonSchema( + example.content, + schemas[example.template], + `${example.id} should be valid` + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js new file mode 100644 index 0000000000..eaef468488 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/TargetingDocs.test.js @@ -0,0 +1,88 @@ +import { ASRouterTargeting } from "lib/ASRouterTargeting.jsm"; +import docs from "content-src/asrouter/docs/targeting-attributes.md"; + +// The following targeting parameters are either deprecated or should not be included in the docs for some reason. +const SKIP_DOCS = []; +// These are extra message context attributes via ASRouter.jsm +const MESSAGE_CONTEXT_ATTRIBUTES = ["previousSessionEnd"]; + +function getHeadingsFromDocs() { + const re = /### `(\w+)`/g; + const found = []; + let match = 1; + while (match) { + match = re.exec(docs); + if (match) { + found.push(match[1]); + } + } + return found; +} + +function getTOCFromDocs() { + const re = /## Available attributes\n+([^]+)\n+## Detailed usage/; + const sectionMatch = docs.match(re); + if (!sectionMatch) { + return []; + } + const [, listText] = sectionMatch; + const re2 = /\[(\w+)\]/g; + const found = []; + let match = 1; + while (match) { + match = re2.exec(listText); + if (match) { + found.push(match[1]); + } + } + return found; +} + +describe("ASRTargeting docs", () => { + const DOCS_TARGETING_HEADINGS = getHeadingsFromDocs(); + const DOCS_TOC = getTOCFromDocs(); + const ASRTargetingAttributes = [ + ...Object.keys(ASRouterTargeting.Environment).filter( + attribute => !SKIP_DOCS.includes(attribute) + ), + ...MESSAGE_CONTEXT_ATTRIBUTES, + ]; + + describe("All targeting params documented in targeting-attributes.md", () => { + for (const targetingParam of ASRTargetingAttributes) { + // If this test is failing, you probably forgot to add docs to content-src/asrouter/targeting-attributes.md + // for a new targeting attribute, or you forgot to put it in the table of contents up top. + it(`should have docs and table of contents entry for ${targetingParam}`, () => { + assert.include( + DOCS_TARGETING_HEADINGS, + targetingParam, + `Didn't find the heading: ### \`${targetingParam}\`` + ); + assert.include( + DOCS_TOC, + targetingParam, + `Didn't find a table of contents entry for ${targetingParam}` + ); + }); + } + }); + describe("No extra attributes in targeting-attributes.md", () => { + // "allow" includes targeting attributes that are not implemented by + // ASRTargetingAttributes. For example trigger context passed to the evaluation + // context in when a trigger runs or ASRouter state used in the evaluation. + const allow = ["messageImpressions", "screenImpressions"]; + for (const targetingParam of DOCS_TARGETING_HEADINGS.filter( + doc => !allow.includes(doc) + )) { + // If this test is failing, you might have spelled something wrong or removed a targeting param without + // removing its docs. + it(`should have an implementation for ${targetingParam} in ASRouterTargeting.Environment`, () => { + assert.include( + ASRTargetingAttributes, + targetingParam, + `Didn't find an implementation for ${targetingParam}` + ); + }); + } + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx new file mode 100644 index 0000000000..b581886111 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/asrouter-content.test.jsx @@ -0,0 +1,516 @@ +import { ASRouterUISurface } from "content-src/asrouter/asrouter-content"; +import { ASRouterUtils } from "content-src/asrouter/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; +import { FAKE_LOCAL_MESSAGES } from "./constants"; +import React from "react"; +import { mount } from "enzyme"; + +let [FAKE_MESSAGE] = FAKE_LOCAL_MESSAGES; +const FAKE_NEWSLETTER_SNIPPET = FAKE_LOCAL_MESSAGES.find( + msg => msg.id === "newsletter" +); +const FAKE_FXA_SNIPPET = FAKE_LOCAL_MESSAGES.find(msg => msg.id === "fxa"); +const FAKE_BELOW_SEARCH_SNIPPET = FAKE_LOCAL_MESSAGES.find( + msg => msg.id === "belowsearch" +); + +FAKE_MESSAGE = Object.assign({}, FAKE_MESSAGE, { provider: "fakeprovider" }); + +describe("ASRouterUtils", () => { + let globalOverrider; + let sandbox; + let globals; + beforeEach(() => { + globalOverrider = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub(), + }; + globalOverrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + it("should send a message with the right payload data", () => { + ASRouterUtils.sendTelemetry({ id: 1, event: "CLICK" }); + + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { + type: "AS_ROUTER_TELEMETRY_USER_EVENT", + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + data: { + id: 1, + event: "CLICK", + }, + }); + }); +}); + +describe("ASRouterUISurface", () => { + let wrapper; + let globalOverrider; + let sandbox; + let headerPortal; + let footerPortal; + let root; + let fakeDocument; + let globals; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + headerPortal = document.createElement("div"); + footerPortal = document.createElement("div"); + root = document.createElement("div"); + sandbox.stub(footerPortal, "querySelector").returns(footerPortal); + fakeDocument = { + location: { href: "" }, + _listeners: new Set(), + _visibilityState: "hidden", + head: { + appendChild(el) { + return el; + }, + }, + get visibilityState() { + return this._visibilityState; + }, + set visibilityState(value) { + if (this._visibilityState === value) { + return; + } + this._visibilityState = value; + this._listeners.forEach(l => l()); + }, + addEventListener(event, listener) { + this._listeners.add(listener); + }, + removeEventListener(event, listener) { + this._listeners.delete(listener); + }, + get body() { + return document.createElement("body"); + }, + getElementById(id) { + switch (id) { + case "header-asrouter-container": + return headerPortal; + case "root": + return root; + default: + return footerPortal; + } + }, + createElement(tag) { + return document.createElement(tag); + }, + }; + globals = { + ASRouterMessage: sandbox.stub().resolves(), + ASRouterAddParentListener: sandbox.stub(), + ASRouterRemoveParentListener: sandbox.stub(), + fetch: sandbox.stub().resolves({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }), + }; + globalOverrider = new GlobalOverrider(); + globalOverrider.set(globals); + sandbox.stub(ASRouterUtils, "sendTelemetry"); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + }); + + afterEach(() => { + sandbox.restore(); + globalOverrider.restore(); + }); + + it("should render the component if a message id is defined", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isTrue(wrapper.exists()); + }); + + it("should pass in the correct form_method for newsletter snippets", () => { + wrapper.setState({ message: FAKE_NEWSLETTER_SNIPPET }); + + assert.isTrue(wrapper.find("SubmitFormSnippet").exists()); + assert.propertyVal( + wrapper.find("SubmitFormSnippet").props(), + "form_method", + "POST" + ); + }); + + it("should pass in the correct form_method for fxa snippets", () => { + wrapper.setState({ message: FAKE_FXA_SNIPPET }); + + assert.isTrue(wrapper.find("SubmitFormSnippet").exists()); + assert.propertyVal( + wrapper.find("SubmitFormSnippet").props(), + "form_method", + "GET" + ); + }); + + it("should render a preview banner if message provider is preview", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + assert.isTrue(wrapper.find(".snippets-preview-banner").exists()); + }); + + it("should not render a preview banner if message provider is not preview", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isFalse(wrapper.find(".snippets-preview-banner").exists()); + }); + + it("should render a SimpleSnippet in the footer portal", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.isTrue(footerPortal.childElementCount > 0); + assert.equal(headerPortal.childElementCount, 0); + }); + + it("should not render a SimpleBelowSearchSnippet in a portal", () => { + wrapper.setState({ message: FAKE_BELOW_SEARCH_SNIPPET }); + assert.equal(headerPortal.childElementCount, 0); + assert.equal(footerPortal.childElementCount, 0); + }); + + it("should dispatch an event to select the correct theme", () => { + const stub = sandbox.stub(window, "dispatchEvent"); + sandbox + .stub(ASRouterUtils, "getPreviewEndpoint") + .returns({ theme: "dark" }); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + + assert.calledOnce(stub); + assert.property(stub.firstCall.args[0].detail.data, "ntp_background"); + assert.property(stub.firstCall.args[0].detail.data, "ntp_text"); + assert.property(stub.firstCall.args[0].detail.data, "sidebar"); + assert.property(stub.firstCall.args[0].detail.data, "sidebar_text"); + }); + + it("should set `dir=rtl` on the page's <html> element if the dir param is set", () => { + assert.notPropertyVal(fakeDocument, "dir", "rtl"); + sandbox.stub(ASRouterUtils, "getPreviewEndpoint").returns({ dir: "rtl" }); + + wrapper = mount(<ASRouterUISurface document={fakeDocument} />); + assert.propertyVal(fakeDocument, "dir", "rtl"); + }); + + describe("snippets", () => { + it("should send correct event and source when snippet is blocked", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + + wrapper.find(".blockButton").simulate("click"); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "event", + "BLOCK" + ); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "source", + "NEWTAB_FOOTER_BAR" + ); + }); + + it("should not send telemetry when a preview snippet is blocked", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + + wrapper.find(".blockButton").simulate("click"); + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + }); + + describe("impressions", () => { + function simulateVisibilityChange(value) { + fakeDocument.visibilityState = value; + } + + it("should call blockById after CTA link is clicked", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + sandbox.stub(ASRouterUtils, "blockById").resolves(); + wrapper.instance().sendClick({ target: { dataset: { metric: "" } } }); + + assert.calledOnce(ASRouterUtils.blockById); + assert.calledWith(ASRouterUtils.blockById, FAKE_MESSAGE.id); + }); + + it("should executeAction if defined on the anchor", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + sandbox.spy(ASRouterUtils, "executeAction"); + wrapper.instance().sendClick({ + target: { dataset: { action: "OPEN_MENU", args: "appMenu" } }, + }); + + assert.calledOnce(ASRouterUtils.executeAction); + assert.calledWithExactly(ASRouterUtils.executeAction, { + type: "OPEN_MENU", + data: { args: "appMenu" }, + }); + }); + + it("should not call blockById if do_not_autoblock is true", () => { + wrapper.setState({ + message: { + ...FAKE_MESSAGE, + ...{ content: { ...FAKE_MESSAGE.content, do_not_autoblock: true } }, + }, + }); + sandbox.stub(ASRouterUtils, "blockById"); + wrapper.instance().sendClick({ target: { dataset: { metric: "" } } }); + + assert.notCalled(ASRouterUtils.blockById); + }); + + it("should not send an impression if no message exists", () => { + simulateVisibilityChange("visible"); + + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should not send an impression if the page is not visible", () => { + simulateVisibilityChange("hidden"); + wrapper.setState({ message: FAKE_MESSAGE }); + + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should not send an impression for a preview message", () => { + wrapper.setState({ message: { ...FAKE_MESSAGE, provider: "preview" } }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + simulateVisibilityChange("visible"); + assert.notCalled(ASRouterUtils.sendTelemetry); + }); + + it("should send an impression ping when there is a message and the page becomes visible", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + simulateVisibilityChange("visible"); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should send the correct impression source", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + simulateVisibilityChange("visible"); + + assert.calledOnce(ASRouterUtils.sendTelemetry); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "event", + "IMPRESSION" + ); + assert.propertyVal( + ASRouterUtils.sendTelemetry.firstCall.args[0], + "source", + "NEWTAB_FOOTER_BAR" + ); + }); + + it("should send an impression ping when the page is visible and a message gets loaded", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: {} }); + assert.notCalled(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should send another impression ping if the message id changes", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: FAKE_LOCAL_MESSAGES[1] }); + assert.calledTwice(ASRouterUtils.sendTelemetry); + }); + + it("should not send another impression ping if the message id has not changed", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ somethingElse: 123 }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should not send another impression ping if the message is cleared", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + + wrapper.setState({ message: {} }); + assert.calledOnce(ASRouterUtils.sendTelemetry); + }); + + it("should call .sendTelemetry with the right message data", () => { + simulateVisibilityChange("visible"); + wrapper.setState({ message: FAKE_MESSAGE }); + + assert.calledOnce(ASRouterUtils.sendTelemetry); + const [payload] = ASRouterUtils.sendTelemetry.firstCall.args; + + assert.propertyVal(payload, "message_id", FAKE_MESSAGE.id); + assert.propertyVal(payload, "event", "IMPRESSION"); + assert.propertyVal( + payload, + "action", + `${FAKE_MESSAGE.provider}_user_event` + ); + assert.propertyVal(payload, "source", "NEWTAB_FOOTER_BAR"); + }); + + it("should construct a OPEN_ABOUT_PAGE action with attribution", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + const stub = sandbox.stub(ASRouterUtils, "executeAction"); + + wrapper.instance().sendClick({ + target: { + dataset: { + metric: "", + entrypoint_value: "snippet", + action: "OPEN_PREFERENCES_PAGE", + args: "home", + }, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, { + type: "OPEN_PREFERENCES_PAGE", + data: { args: "home", entrypoint: "snippet" }, + }); + }); + + it("should construct a OPEN_ABOUT_PAGE action with attribution", () => { + wrapper.setState({ message: FAKE_MESSAGE }); + const stub = sandbox.stub(ASRouterUtils, "executeAction"); + + wrapper.instance().sendClick({ + target: { + dataset: { + metric: "", + entrypoint_name: "entryPoint", + entrypoint_value: "snippet", + action: "OPEN_ABOUT_PAGE", + args: "logins", + }, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, { + type: "OPEN_ABOUT_PAGE", + data: { args: "logins", entrypoint: "entryPoint=snippet" }, + }); + }); + }); + + describe(".fetchFlowParams", () => { + let dispatchStub; + const assertCalledWithURL = url => + assert.calledWith(globals.fetch, new URL(url).toString(), { + credentials: "omit", + }); + beforeEach(() => { + dispatchStub = sandbox.stub(); + wrapper = mount( + <ASRouterUISurface + dispatch={dispatchStub} + fxaEndpoint="https://accounts.firefox.com" + /> + ); + }); + it("should use the base url returned from the endpoint pref", async () => { + wrapper = mount( + <ASRouterUISurface + dispatch={dispatchStub} + fxaEndpoint="https://foo.com" + /> + ); + await wrapper.instance().fetchFlowParams(); + + assertCalledWithURL("https://foo.com/metrics-flow"); + }); + it("should add given search params to the URL", async () => { + const params = { foo: "1", bar: "2" }; + + await wrapper.instance().fetchFlowParams(params); + + assertCalledWithURL( + "https://accounts.firefox.com/metrics-flow?foo=1&bar=2" + ); + }); + it("should return flowId, flowBeginTime, deviceId on a 200 response", async () => { + const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" }; + globals.fetch + .withArgs("https://accounts.firefox.com/metrics-flow") + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(flowInfo), + }); + + const result = await wrapper.instance().fetchFlowParams(); + assert.deepEqual(result, flowInfo); + }); + + describe(".onUserAction", () => { + it("if the action.type is ENABLE_FIREFOX_MONITOR, it should generate the right monitor URL given some flowParams", async () => { + const flowInfo = { flowId: "foo", flowBeginTime: 123, deviceId: "bar" }; + globals.fetch + .withArgs( + "https://accounts.firefox.com/metrics-flow?utm_term=avocado" + ) + .resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(flowInfo), + }); + + sandbox.spy(ASRouterUtils, "executeAction"); + + const msg = { + type: "ENABLE_FIREFOX_MONITOR", + data: { + args: { + url: "https://monitor.firefox.com?foo=bar", + flowRequestParams: { + utm_term: "avocado", + }, + }, + }, + }; + + await wrapper.instance().onUserAction(msg); + + assertCalledWithURL( + "https://accounts.firefox.com/metrics-flow?utm_term=avocado" + ); + assert.calledWith(ASRouterUtils.executeAction, { + type: "OPEN_URL", + data: { + args: new URL( + "https://monitor.firefox.com?foo=bar&deviceId=bar&flowId=foo&flowBeginTime=123" + ).toString(), + }, + }); + }); + it("if the action.type is not ENABLE_FIREFOX_MONITOR, it should just call ASRouterUtils.executeAction", async () => { + const msg = { + type: "FOO", + data: { + args: "bar", + }, + }; + sandbox.spy(ASRouterUtils, "executeAction"); + await wrapper.instance().onUserAction(msg); + assert.calledWith(ASRouterUtils.executeAction, msg); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js new file mode 100644 index 0000000000..1027b8ddab --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/asrouter-utils.test.js @@ -0,0 +1,100 @@ +import { ASRouterUtils } from "content-src/asrouter/asrouter-utils"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("ASRouterUtils", () => { + let globals = null; + let overrider = null; + let sandbox = null; + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = { + ASRouterMessage: sandbox.stub().resolves({}), + }; + overrider = new GlobalOverrider(); + overrider.set(globals); + }); + afterEach(() => { + sandbox.restore(); + overrider.restore(); + }); + describe("sendMessage", () => { + it("default", () => { + ASRouterUtils.sendMessage({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + assert.calledWith(globals.ASRouterMessage, { foo: "bar" }); + }); + it("throws if ASRouterMessage is not defined", () => { + overrider.set("ASRouterMessage", undefined); + assert.throws(() => ASRouterUtils.sendMessage({ foo: "bar" })); + }); + }); + describe("blockById", () => { + it("default", () => { + ASRouterUtils.blockById(1, { foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar", id: 1 } }) + ); + }); + }); + describe("modifyMessageJson", () => { + it("default", () => { + ASRouterUtils.modifyMessageJson({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { content: { foo: "bar" } } }) + ); + }); + }); + describe("executeAction", () => { + it("default", () => { + ASRouterUtils.executeAction({ foo: "bar" }); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { foo: "bar" } }) + ); + }); + }); + describe("unblockById", () => { + it("default", () => { + ASRouterUtils.unblockById(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 2 } }) + ); + }); + }); + describe("blockBundle", () => { + it("default", () => { + ASRouterUtils.blockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("unblockBundle", () => { + it("default", () => { + ASRouterUtils.unblockBundle(2); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { bundle: 2 } }) + ); + }); + }); + describe("overrideMessage", () => { + it("default", () => { + ASRouterUtils.overrideMessage(12); + assert.calledWith( + globals.ASRouterMessage, + sinon.match({ data: { id: 12 } }) + ); + }); + }); + describe("sendTelemetry", () => { + it("default", () => { + ASRouterUtils.sendTelemetry({ foo: "bar" }); + assert.calledOnce(globals.ASRouterMessage); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js new file mode 100644 index 0000000000..335318d9c6 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/fx57-compat.test.js @@ -0,0 +1,26 @@ +import EOYSnippetSchema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; +import { expectedValues } from "./snippets-fx57"; +import SimpleSnippetSchema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import SubmitFormSchema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; + +export const SnippetsSchemas = { + eoy_snippet: EOYSnippetSchema, + simple_snippet: SimpleSnippetSchema, + newsletter_snippet: SubmitFormSchema, + fxa_signup_snippet: SubmitFormSchema, + send_to_device_snippet: SubmitFormSchema, +}; + +describe("Firefox 57 compatibility test", () => { + Object.keys(expectedValues).forEach(template => { + describe(template, () => { + const schema = SnippetsSchemas[template]; + it(`should have a schema for ${template}`, () => { + assert.ok(schema); + }); + it(`should validate with the schema for ${template}`, () => { + assert.jsonSchema(expectedValues[template], schema); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js new file mode 100644 index 0000000000..e63256315b --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/compatibility-reference/snippets-fx57.js @@ -0,0 +1,125 @@ +/** + * IMPORTANT NOTE!!! + * + * Please DO NOT introduce breaking changes file without contacting snippets endpoint engineers + * and changing the schema version to reflect a breaking change. + * + */ + +const DATA_URI_IMAGE = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + +export const expectedValues = { + // Simple Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/simple-snippet.html) + simple_snippet: { + icon: DATA_URI_IMAGE, + button_label: "Click me", + button_url: "https://mozilla.org", + button_background_color: "#FF0000", + button_color: "#FFFFFF", + text: "Hello world", + title: "Hi!", + title_icon: DATA_URI_IMAGE, + tall: true, + }, + + // FXA Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/fxa.html) + fxa_signup_snippet: { + scene1_icon: DATA_URI_IMAGE, + scene1_button_label: "Click me", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_text: "Hello <em>world</em>", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_text: "Second scene", + scene2_title: "Second scene title", + scene2_email_placeholder_text: "Email here", + scene2_button_label: "Sign Me Up", + scene2_dismiss_button_text: "Dismiss", + + utm_campaign: "snippets123", + utm_term: "123term", + }, + + // Send To Device Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/send-to-device.html) + send_to_device_snippet: { + include_sms: true, + locale: "de", + country: "DE", + message_id_sms: "foo", + message_id_email: "foo", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_button_label: "Click me", + scene1_icon: DATA_URI_IMAGE, + scene1_text: "Hello world", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_button_label: "Sign Me Up", + scene2_disclaimer_html: "Hello <em>world</em>", + scene2_dismiss_button_text: "Dismiss", + scene2_icon: DATA_URI_IMAGE, + scene2_input_placeholder: "Email here", + + scene2_text: "Second scene", + scene2_title: "Second scene title", + + error_text: "error", + success_text: "all good", + success_title: "Ok!", + }, + + // Newsletter Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/newsletter-subscribe.html) + newsletter_snippet: { + scene1_icon: DATA_URI_IMAGE, + scene1_button_label: "Click me", + scene1_button_background_color: "#FF0000", + scene1_button_color: "#FFFFFF", + scene1_text: "Hello world", + scene1_title: "Hi!", + scene1_title_icon: DATA_URI_IMAGE, + + scene2_text: "Second scene", + scene2_title: "Second scene title", + scene2_newsletter: "foo", + scene2_email_placeholder_text: "Email here", + scene2_button_label: "Sign Me Up", + scene2_privacy_html: "Hello <em>world</em>", + scene2_dismiss_button_text: "Dismiss", + + locale: "de", + + error_text: "error", + success_text: "all good", + }, + + // EOY Snippet (https://github.com/mozmeao/snippets/blob/master/activity-stream/mofo-eoy-2017.html) + eoy_snippet: { + block_button_text: "Block", + + donation_form_url: "https://donate.mozilla.org/", + text: "Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The not-for-profit Mozilla Foundation fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; will you donate today?", + icon: DATA_URI_IMAGE, + button_label: "Donate", + monthly_checkbox_label_text: "Make my donation monthly", + button_background_color: "#0060DF", + button_color: "#FFFFFF", + background_color: "#FFFFFF", + text_color: "#000000", + highlight_color: "#FFE900", + + locale: "en-US", + currency_code: "usd", + + donation_amount_first: 50, + donation_amount_second: 25, + donation_amount_third: 10, + donation_amount_fourth: 3, + selected_button: "donation_amount_second", + + test: "bold", + }, +}; diff --git a/browser/components/newtab/test/unit/asrouter/constants.js b/browser/components/newtab/test/unit/asrouter/constants.js new file mode 100644 index 0000000000..392ca66ae3 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/constants.js @@ -0,0 +1,137 @@ +export const CHILD_TO_PARENT_MESSAGE_NAME = "ASRouter:child-to-parent"; +export const PARENT_TO_CHILD_MESSAGE_NAME = "ASRouter:parent-to-child"; + +export const FAKE_LOCAL_MESSAGES = [ + { + id: "foo", + provider: "snippets", + template: "simple_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "foo1", + template: "simple_snippet", + provider: "snippets", + bundled: 2, + order: 1, + content: { title: "Foo1", body: "Foo123-1" }, + }, + { + id: "foo2", + template: "simple_snippet", + provider: "snippets", + bundled: 2, + order: 2, + content: { title: "Foo2", body: "Foo123-2" }, + }, + { + id: "bar", + template: "fancy_template", + content: { title: "Foo", body: "Foo123" }, + }, + { id: "baz", content: { title: "Foo", body: "Foo123" } }, + { + id: "newsletter", + provider: "snippets", + template: "newsletter_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "fxa", + provider: "snippets", + template: "fxa_signup_snippet", + content: { title: "Foo", body: "Foo123" }, + }, + { + id: "belowsearch", + provider: "snippets", + template: "simple_below_search_snippet", + content: { text: "Foo" }, + }, +]; +export const FAKE_LOCAL_PROVIDER = { + id: "onboarding", + type: "local", + localProvider: "FAKE_LOCAL_PROVIDER", + enabled: true, + cohort: 0, +}; +export const FAKE_LOCAL_PROVIDERS = { + FAKE_LOCAL_PROVIDER: { + getMessages: () => Promise.resolve(FAKE_LOCAL_MESSAGES), + }, +}; + +export const FAKE_REMOTE_MESSAGES = [ + { + id: "qux", + template: "simple_snippet", + content: { title: "Qux", body: "hello world" }, + }, +]; +export const FAKE_REMOTE_PROVIDER = { + id: "remotey", + type: "remote", + url: "http://fake.com/endpoint", + enabled: true, +}; + +export const FAKE_REMOTE_SETTINGS_PROVIDER = { + id: "remotey-settingsy", + type: "remote-settings", + collection: "collectionname", + enabled: true, +}; + +const notificationText = new String("Fake notification text"); // eslint-disable-line +notificationText.attributes = { tooltiptext: "Fake tooltip text" }; + +export const FAKE_RECOMMENDATION = { + id: "fake_id", + template: "cfr_doorhanger", + content: { + category: "cfrDummy", + bucket_id: "fake_bucket_id", + notification_text: notificationText, + info_icon: { + label: "Fake Info Icon Label", + sumo_path: "a_help_path_fragment", + }, + heading_text: "Fake Heading Text", + icon_class: "Fake Icon class", + addon: { + title: "Fake Addon Title", + author: "Fake Addon Author", + icon: "a_path_to_some_icon", + rating: "4.2", + users: "1234", + amo_url: "a_path_to_amo", + }, + descriptionDetails: { + steps: [{ string_id: "cfr-features-step1" }], + }, + text: "Here is the recommendation text body", + buttons: { + primary: { + label: { string_id: "primary_button_id" }, + action: { + id: "primary_action", + data: {}, + }, + }, + secondary: [ + { + label: { string_id: "secondary_button_id" }, + action: { id: "secondary_action" }, + }, + { + label: { string_id: "secondary_button_id_2" }, + }, + { + label: { string_id: "secondary_button_id_3" }, + action: { id: "secondary_action" }, + }, + ], + }, + }, +}; diff --git a/browser/components/newtab/test/unit/asrouter/template-utils.test.js b/browser/components/newtab/test/unit/asrouter/template-utils.test.js new file mode 100644 index 0000000000..e5f4b5ef4d --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/template-utils.test.js @@ -0,0 +1,31 @@ +import { safeURI } from "content-src/asrouter/template-utils"; + +describe("safeURI", () => { + let warnStub; + beforeEach(() => { + warnStub = sinon.stub(console, "warn"); + }); + afterEach(() => { + warnStub.restore(); + }); + it("should allow http: URIs", () => { + assert.equal(safeURI("http://foo.com"), "http://foo.com"); + }); + it("should allow https: URIs", () => { + assert.equal(safeURI("https://foo.com"), "https://foo.com"); + }); + it("should allow data URIs", () => { + assert.equal( + safeURI("data:image/png;base64,iVBO"), + "data:image/png;base64,iVBO" + ); + }); + it("should not allow javascript: URIs", () => { + assert.equal(safeURI("javascript:foo()"), ""); // eslint-disable-line no-script-url + assert.calledOnce(warnStub); + }); + it("should not warn if the URL is falsey ", () => { + assert.equal(safeURI(), ""); + assert.notCalled(warnStub); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx new file mode 100644 index 0000000000..bd4ab00468 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/EOYSnippet.test.jsx @@ -0,0 +1,213 @@ +import { EOYSnippet } from "content-src/asrouter/templates/EOYSnippet/EOYSnippet"; +import { GlobalOverrider } from "test/unit/utils"; +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json"; + +const DEFAULT_CONTENT = { + text: "foo", + donation_amount_first: 50, + donation_amount_second: 25, + donation_amount_third: 10, + donation_amount_fourth: 5, + donation_form_url: "https://submit.form", + button_label: "Donate", +}; + +describe("EOYSnippet", () => { + let sandbox; + let wrapper; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a EOYSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for EOYSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + provider, + onAction: sandbox.stub(), + onBlock: sandbox.stub(), + sendClick: sandbox.stub(), + }; + const comp = mount( + <EOYSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + wrapper = mountAndCheckProps(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + wrapper = mountAndCheckProps(); + // SendToDeviceSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 4); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + }); + + it("should render 4 donation options", () => { + assert.lengthOf(wrapper.find("input[type='radio']"), 4); + }); + + it("should have a data-metric field", () => { + assert.ok(wrapper.find("form[data-metric='EOYSnippetForm']").exists()); + }); + + it("should select the second donation option", () => { + wrapper = mountAndCheckProps({ selected_button: "donation_amount_second" }); + + assert.propertyVal( + wrapper.find("input[type='radio']").get(1).props, + "defaultChecked", + true + ); + }); + + it("should set frequency value to monthly", () => { + const form = wrapper.find("form").instance(); + assert.equal(form.querySelector("[name='frequency']").value, "single"); + + form.querySelector("#monthly-checkbox").checked = true; + wrapper.find("form").simulate("submit"); + + assert.equal(form.querySelector("[name='frequency']").value, "monthly"); + }); + + it("should block after submitting the form", () => { + const onBlockStub = sandbox.stub(); + wrapper.setProps({ onBlock: onBlockStub }); + + wrapper.find("form").simulate("submit"); + + assert.calledOnce(onBlockStub); + }); + + it("should not block if do_not_autoblock is true", () => { + const onBlockStub = sandbox.stub(); + wrapper = mountAndCheckProps({ do_not_autoblock: true }); + wrapper.setProps({ onBlock: onBlockStub }); + + wrapper.find("form").simulate("submit"); + + assert.notCalled(onBlockStub); + }); + + it("should report form submissions", () => { + wrapper = mountAndCheckProps(); + const { sendClick } = wrapper.props(); + + wrapper.find("form").simulate("submit"); + + assert.calledOnce(sendClick); + assert.equal( + sendClick.firstCall.args[0].target.dataset.metric, + "EOYSnippetForm" + ); + }); + + it("it should preserve URL GET params as hidden inputs", () => { + wrapper = mountAndCheckProps({ + donation_form_url: + "https://donate.mozilla.org/pl/?utm_source=desktop-snippet&utm_medium=snippet&utm_campaign=donate&utm_term=7556", + }); + + const hiddenInputs = wrapper.find("input[type='hidden']"); + + assert.propertyVal( + hiddenInputs.find("[name='utm_source']").props(), + "value", + "desktop-snippet" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_medium']").props(), + "value", + "snippet" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_campaign']").props(), + "value", + "donate" + ); + assert.propertyVal( + hiddenInputs.find("[name='amp;utm_term']").props(), + "value", + "7556" + ); + }); + + describe("locale", () => { + let stub; + let globals; + beforeEach(() => { + globals = new GlobalOverrider(); + stub = sandbox.stub().returns({ format: () => {} }); + + globals = new GlobalOverrider(); + globals.set({ Intl: { NumberFormat: stub } }); + }); + afterEach(() => { + globals.restore(); + }); + + it("should use content.locale for Intl", () => { + // triggers component rendering and calls the function we're testing + wrapper.setProps({ + content: { + locale: "locale-foo", + donation_form_url: DEFAULT_CONTENT.donation_form_url, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, "locale-foo", sinon.match.object); + }); + + it("should use navigator.language as locale fallback", () => { + // triggers component rendering and calls the function we're testing + wrapper.setProps({ + content: { + locale: null, + donation_form_url: DEFAULT_CONTENT.donation_form_url, + }, + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, navigator.language, sinon.match.object); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx new file mode 100644 index 0000000000..bef14c6982 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/ExtensionDoorhanger.test.jsx @@ -0,0 +1,112 @@ +import { CFRMessageProvider } from "lib/CFRMessageProvider.sys.mjs"; +import CFRDoorhangerSchema from "content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json"; +import CFRChicletSchema from "content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json"; +import InfoBarSchema from "content-src/asrouter/templates/CFR/templates/InfoBar.schema.json"; + +const SCHEMAS = { + cfr_urlbar_chiclet: CFRChicletSchema, + cfr_doorhanger: CFRDoorhangerSchema, + milestone_message: CFRDoorhangerSchema, + infobar: InfoBarSchema, +}; + +const DEFAULT_CONTENT = { + layout: "addon_recommendation", + category: "dummyCategory", + bucket_id: "some_bucket_id", + notification_text: "Recommendation", + heading_text: "Recommended Extension", + info_icon: { + label: { attributes: { tooltiptext: "Why am I seeing this" } }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: "Description of addon", + buttons: { + primary: { + label: { + value: "Add Now", + attributes: { accesskey: "A" }, + }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { + value: "Not Now", + attributes: { accesskey: "N" }, + }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +const L10N_CONTENT = { + layout: "addon_recommendation", + category: "dummyL10NCategory", + bucket_id: "some_bucket_id", + notification_text: { string_id: "notification_text_id" }, + heading_text: { string_id: "heading_text_id" }, + info_icon: { + label: { string_id: "why_seeing_this" }, + sumo_path: "extensionrecommendations", + }, + addon: { + id: "1234", + title: "Addon name", + icon: "https://mozilla.org/icon", + author: "Author name", + amo_url: "https://example.com", + }, + text: { string_id: "text_id" }, + buttons: { + primary: { + label: { string_id: "btn_ok_id" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com" }, + }, + }, + secondary: [ + { + label: { string_id: "btn_cancel_id" }, + action: { type: "CANCEL" }, + }, + ], + }, +}; + +describe("ExtensionDoorhanger", () => { + it("should validate DEFAULT_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: DEFAULT_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate L10N_CONTENT", async () => { + const messages = await CFRMessageProvider.getMessages(); + let doorhangerMessage = messages.find(m => m.id === "FACEBOOK_CONTAINER_3"); + assert.ok(doorhangerMessage, "Message found"); + assert.jsonSchema( + { ...doorhangerMessage, content: L10N_CONTENT }, + CFRDoorhangerSchema + ); + }); + it("should validate all messages from CFRMessageProvider", async () => { + const messages = await CFRMessageProvider.getMessages(); + messages.forEach(msg => assert.jsonSchema(msg, SCHEMAS[msg.template])); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx new file mode 100644 index 0000000000..56828d266b --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/FXASignupSnippet.test.jsx @@ -0,0 +1,106 @@ +import { FXASignupSnippet } from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet"; +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +describe("FXASignupSnippet", () => { + let DEFAULT_CONTENT; + let sandbox; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign( + { utm_campaign: "foo", utm_term: "bar" }, + DEFAULT_CONTENT, + content + ), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <FXASignupSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "fxa_signup_snippet" + ).content; + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const defaults = { + id: "foo123", + onBlock() {}, + content: {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const wrapper = mount( + <FXASignupSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // FXASignupSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 5); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter(prop => schema.properties.hidden_inputs.properties[prop].default); + assert.lengthOf(defaultHiddenProperties, 0); + }); + + it("should have a form_action", () => { + const wrapper = mountAndCheckProps(); + + assert.propertyVal( + wrapper.children().get(0).props, + "form_action", + "https://accounts.firefox.com/" + ); + }); + + it("should navigate to scene2", () => { + const wrapper = mountAndCheckProps({}); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.lengthOf(wrapper.find(".mainInput"), 1); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx new file mode 100644 index 0000000000..cb80abdae0 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/NewsletterSnippet.test.jsx @@ -0,0 +1,108 @@ +import { mount } from "enzyme"; +import { NewsletterSnippet } from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +describe("NewsletterSnippet", () => { + let sandbox; + let DEFAULT_CONTENT; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <NewsletterSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "newsletter_snippet" + ).content; + }); + afterEach(() => { + sandbox.restore(); + }); + + describe("schema test", () => { + it("should validate the schema and defaults", () => { + const wrapper = mountAndCheckProps(); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "email"); + }); + + it("should have all of the default fields", () => { + const defaults = { + id: "foo123", + content: {}, + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const wrapper = mount( + <NewsletterSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // NewsletterSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + // the `locale` properties gets used as part of hidden_fields so we + // check for it separately + const properties = { ...schema.properties }; + const { locale } = properties; + delete properties.locale; + + const defaultProperties = Object.keys(properties).filter( + prop => properties[prop].default + ); + assert.lengthOf(defaultProperties, 6); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter( + prop => schema.properties.hidden_inputs.properties[prop].default + ); + assert.lengthOf(defaultHiddenProperties, 1); + defaultHiddenProperties.forEach(prop => + assert.propertyVal( + props.content.hidden_inputs, + prop, + schema.properties.hidden_inputs.properties[prop].default + ) + ); + assert.propertyVal(props.content.hidden_inputs, "lang", locale.default); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx new file mode 100644 index 0000000000..3c60967643 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SendToDeviceSnippet.test.jsx @@ -0,0 +1,277 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json"; +import { + SendToDeviceSnippet, + SendToDeviceScene2Snippet, +} from "content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet"; +import { SnippetsTestMessageProvider } from "lib/SnippetsTestMessageProvider.sys.mjs"; + +async function testBodyContains(body, key, value) { + const regex = new RegExp( + `Content-Disposition: form-data; name="${key}"${value}` + ); + const match = regex.exec(body); + return match; +} + +/** + * Simulates opening the second panel (form view), filling in the input, and submitting + * @param {EnzymeWrapper} wrapper A SendToDevice wrapper + * @param {string} value Email or phone number + * @param {function?} setCustomValidity setCustomValidity stub + */ +function openFormAndSetValue(wrapper, value, setCustomValidity = () => {}) { + // expand + wrapper.find(".ASRouterButton").simulate("click"); + // Fill in email + const input = wrapper.find(".mainInput"); + input.instance().value = value; + input.simulate("change", { target: { value, setCustomValidity } }); + wrapper.find("form").simulate("submit"); +} + +describe("SendToDeviceSnippet", () => { + let sandbox; + let fetchStub; + let jsonResponse; + let DEFAULT_CONTENT; + let DEFAULT_SCENE2_CONTENT; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + function mountAndCheckProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + const comp = mount( + <SendToDeviceSnippet {...props} />, + mockL10nWrapper(props.content) + ); + // Check schema with the final props the component receives (including defaults) + assert.jsonSchema(comp.children().get(0).props.content, schema); + return comp; + } + + beforeEach(async () => { + DEFAULT_CONTENT = (await SnippetsTestMessageProvider.getMessages()).find( + msg => msg.template === "send_to_device_snippet" + ).content; + DEFAULT_SCENE2_CONTENT = ( + await SnippetsTestMessageProvider.getMessages() + ).find(msg => msg.template === "send_to_device_scene2_snippet").content; + sandbox = sinon.createSandbox(); + jsonResponse = { status: "ok" }; + fetchStub = sandbox + .stub(global, "fetch") + .returns(Promise.resolve({ json: () => Promise.resolve(jsonResponse) })); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const defaults = { + id: "foo123", + onBlock() {}, + content: {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + form_method: "POST", + }; + const wrapper = mount( + <SendToDeviceSnippet {...defaults} />, + mockL10nWrapper(DEFAULT_CONTENT) + ); + // SendToDeviceSnippet is a wrapper around SubmitFormSnippet + const { props } = wrapper.children().get(0); + + const defaultProperties = Object.keys(schema.properties).filter( + prop => schema.properties[prop].default + ); + assert.lengthOf(defaultProperties, 7); + defaultProperties.forEach(prop => + assert.propertyVal(props.content, prop, schema.properties[prop].default) + ); + + const defaultHiddenProperties = Object.keys( + schema.properties.hidden_inputs.properties + ).filter(prop => schema.properties.hidden_inputs.properties[prop].default); + assert.lengthOf(defaultHiddenProperties, 0); + }); + + describe("form input", () => { + it("should set the input type to text if content.include_sms is true", () => { + const wrapper = mountAndCheckProps({ include_sms: true }); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "text"); + }); + it("should set the input type to email if content.include_sms is false", () => { + const wrapper = mountAndCheckProps({ include_sms: false }); + wrapper.find(".ASRouterButton").simulate("click"); + assert.equal(wrapper.find(".mainInput").instance().type, "email"); + }); + it("should validate the input with isEmailOrPhoneNumber if include_sms is true", () => { + const wrapper = mountAndCheckProps({ include_sms: true }); + const setCustomValidity = sandbox.stub(); + openFormAndSetValue(wrapper, "foo", setCustomValidity); + assert.calledWith( + setCustomValidity, + "Must be an email or a phone number." + ); + }); + it("should not custom validate the input if include_sms is false", () => { + const wrapper = mountAndCheckProps({ include_sms: false }); + const setCustomValidity = sandbox.stub(); + openFormAndSetValue(wrapper, "foo", setCustomValidity); + assert.notCalled(setCustomValidity); + }); + }); + + describe("submitting", () => { + it("should send the right information to basket.mozilla.org/news/subscribe for an email", async () => { + const wrapper = mountAndCheckProps({ + locale: "fr-CA", + include_sms: true, + message_id_email: "foo", + }); + + openFormAndSetValue(wrapper, "foo@bar.com"); + wrapper.find("form").simulate("submit"); + + assert.calledOnce(fetchStub); + const [request] = fetchStub.firstCall.args; + + assert.equal(request.url, "https://basket.mozilla.org/news/subscribe/"); + const body = await request.text(); + assert.ok(testBodyContains(body, "email", "foo@bar.com"), "has email"); + assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang"); + assert.ok( + testBodyContains(body, "newsletters", "foo"), + "has newsletters" + ); + assert.ok( + testBodyContains(body, "source_url", "foo"), + "https%3A%2F%2Fsnippets.mozilla.com%2Fshow%2Ffoo123" + ); + }); + it("should send the right information for an sms", async () => { + const wrapper = mountAndCheckProps({ + locale: "fr-CA", + include_sms: true, + message_id_sms: "foo", + country: "CA", + }); + + openFormAndSetValue(wrapper, "5371283767"); + wrapper.find("form").simulate("submit"); + + assert.calledOnce(fetchStub); + const [request] = fetchStub.firstCall.args; + + assert.equal( + request.url, + "https://basket.mozilla.org/news/subscribe_sms/" + ); + const body = await request.text(); + assert.ok( + testBodyContains(body, "mobile_number", "5371283767"), + "has number" + ); + assert.ok(testBodyContains(body, "lang", "fr-CA"), "has lang"); + assert.ok(testBodyContains(body, "country", "CA"), "CA"); + assert.ok(testBodyContains(body, "msg_name", "foo"), "has msg_name"); + }); + }); + + describe("SendToDeviceScene2Snippet", () => { + function mountWithProps(content = {}) { + const props = { + id: "foo123", + content: Object.assign({}, DEFAULT_SCENE2_CONTENT, content), + onBlock() {}, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + }; + return mount( + <SendToDeviceScene2Snippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + it("should render scene 2", () => { + const wrapper = mountWithProps(); + + assert.lengthOf(wrapper.find(".scene2Icon"), 1, "Found scene 2 icon"); + assert.lengthOf( + wrapper.find(".scene2Title"), + 0, + "Should not have a large header" + ); + }); + it("should have block button", () => { + const wrapper = mountWithProps(); + + assert.lengthOf( + wrapper.find(".blockButton"), + 1, + "Found the block button" + ); + }); + it("should render title text", () => { + const wrapper = mountWithProps(); + + assert.lengthOf( + wrapper.find(".section-title-text"), + 1, + "Found the section title" + ); + assert.lengthOf( + wrapper.find(".section-title .icon"), + 2, // light and dark theme + "Found scene 2 title" + ); + }); + it("should wrap the header in an anchor tag if condition is defined", () => { + const sectionTitleProp = { + section_title_url: "https://support.mozilla.org", + }; + let wrapper = mountWithProps(sectionTitleProp); + + const element = wrapper.find(".section-title a"); + assert.lengthOf(element, 1); + }); + it("should render a header without an anchor", () => { + const sectionTitleProp = { + section_title_url: undefined, + }; + let wrapper = mountWithProps(sectionTitleProp); + assert.lengthOf(wrapper.find(".section-title a"), 0); + assert.equal( + wrapper.find(".section-title").instance().innerText, + DEFAULT_SCENE2_CONTENT.section_title_text + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx new file mode 100644 index 0000000000..df9e544a54 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx @@ -0,0 +1,81 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json"; +import { SimpleBelowSearchSnippet } from "content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx"; + +const DEFAULT_CONTENT = { text: "foo" }; + +describe("SimpleBelowSearchSnippet", () => { + let sandbox; + let sendUserActionTelemetryStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SimpleBelowSearchSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon}) + * @returns enzyme wrapper for SimpleSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: { ...DEFAULT_CONTENT, ...content }, + provider, + sendUserActionTelemetry: sendUserActionTelemetryStub, + onAction: sandbox.stub(), + }; + assert.jsonSchema(props.content, schema); + return mount( + <SimpleBelowSearchSnippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sendUserActionTelemetryStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + + it("should render .icon (light theme)", () => { + const wrapper = mountAndCheckProps({ + icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + + it("should render .icon (dark theme)", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx new file mode 100644 index 0000000000..7c169525e4 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx @@ -0,0 +1,259 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import schema from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json"; +import { SimpleSnippet } from "content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx"; + +const DEFAULT_CONTENT = { text: "foo" }; + +describe("SimpleSnippet", () => { + let sandbox; + let onBlockStub; + let sendUserActionTelemetryStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SimpleSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for SimpleSnippet + */ + function mountAndCheckProps(content = {}, provider = "test-provider") { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + provider, + onBlock: onBlockStub, + sendUserActionTelemetry: sendUserActionTelemetryStub, + onAction: sandbox.stub(), + }; + assert.jsonSchema(props.content, schema); + return mount(<SimpleSnippet {...props} />, mockL10nWrapper(props.content)); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + onBlockStub = sandbox.stub(); + sendUserActionTelemetryStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should have the correct defaults", () => { + const wrapper = mountAndCheckProps(); + [["button", "title", "block_button_text"]].forEach(prop => { + const props = wrapper.find(prop[0]).props(); + assert.propertyVal(props, prop[1], schema.properties[prop[2]].default); + }); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + it("should not render title element if no .title prop is supplied", () => { + const wrapper = mountAndCheckProps(); + assert.lengthOf(wrapper.find(".title"), 0); + }); + it("should render .title", () => { + const wrapper = mountAndCheckProps({ title: "Foo" }); + assert.equal(wrapper.find(".title").text().trim(), "Foo"); + }); + it("should render a light theme variant .icon", () => { + const wrapper = mountAndCheckProps({ + icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render a dark theme variant .icon", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render a light theme variant .icon as fallback", () => { + const wrapper = mountAndCheckProps({ + icon_dark_theme: "", + icon: "data:image/gif;base64,R0lGODp", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODp" + ); + }); + it("should render .button_label and default className", () => { + const wrapper = mountAndCheckProps({ + button_label: "Click here", + button_action: "OPEN_APPLICATIONS_MENU", + button_action_args: "appMenu", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.equal(button.text(), "Click here"); + assert.equal(button.prop("className"), "ASRouterButton secondary"); + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_APPLICATIONS_MENU", + data: { args: "appMenu" }, + }); + }); + it("should not wrap the main content if a section header is not present", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + assert.lengthOf(wrapper.find(".innerContentWrapper"), 0); + }); + it("should wrap the main content if a section header is present", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.lengthOf(wrapper.find(".innerContentWrapper"), 1); + }); + it("should render a section header if text and icon (light-theme) are specified", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.equal( + wrapper.find(".section-title .icon-light-theme").prop("style") + .backgroundImage, + 'url("data:image/gif;base64,R0lGODl")' + ); + assert.equal( + wrapper.find(".section-title-text").text().trim(), + "Messages from Mozilla" + ); + // ensure there is no <a> when a section_title_url is not specified + assert.lengthOf(wrapper.find(".section-title a"), 0); + }); + it("should render a section header if text and icon (light-theme) are specified", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_icon_dark_theme: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + }); + + assert.equal( + wrapper.find(".section-title .icon-dark-theme").prop("style") + .backgroundImage, + 'url("data:image/gif;base64,R0lGODl")' + ); + assert.equal( + wrapper.find(".section-title-text").text().trim(), + "Messages from Mozilla" + ); + // ensure there is no <a> when a section_title_url is not specified + assert.lengthOf(wrapper.find(".section-title a"), 0); + }); + it("should render a section header wrapped in an <a> tag if a url is provided", () => { + const wrapper = mountAndCheckProps({ + section_title_icon: "data:image/gif;base64,R0lGODl", + section_title_text: "Messages from Mozilla", + section_title_url: "https://www.mozilla.org", + }); + + assert.equal( + wrapper.find(".section-title a").prop("href"), + "https://www.mozilla.org" + ); + }); + it("should send an OPEN_URL action when button_url is defined and button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_url: "https://mozilla.org", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_URL", + data: { args: "https://mozilla.org" }, + }); + }); + it("should send an OPEN_ABOUT_PAGE action with entrypoint when the button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_action: "OPEN_ABOUT_PAGE", + button_entrypoint_value: "snippet", + button_entrypoint_name: "entryPoint", + button_action_args: "logins", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_ABOUT_PAGE", + data: { args: "logins", entrypoint: "entryPoint=snippet" }, + }); + }); + it("should send an OPEN_PREFERENCE_PAGE action with entrypoint when the button is clicked", () => { + const wrapper = mountAndCheckProps({ + button_label: "Button", + button_action: "OPEN_PREFERENCE_PAGE", + button_entrypoint_value: "entry=snippet", + button_action_args: "home", + }); + + const button = wrapper.find("button.ASRouterButton"); + button.simulate("click"); + + assert.calledOnce(wrapper.props().onAction); + assert.calledWithExactly(wrapper.props().onAction, { + type: "OPEN_PREFERENCE_PAGE", + data: { args: "home", entrypoint: "entry=snippet" }, + }); + }); + it("should call props.onBlock and sendUserActionTelemetry when CTA button is clicked", () => { + const wrapper = mountAndCheckProps({ text: "bar" }); + + wrapper.instance().onButtonClick(); + + assert.calledOnce(onBlockStub); + assert.calledOnce(sendUserActionTelemetryStub); + }); + + it("should not call props.onBlock if do_not_autoblock is true", () => { + const wrapper = mountAndCheckProps({ text: "bar", do_not_autoblock: true }); + + wrapper.instance().onButtonClick(); + + assert.notCalled(onBlockStub); + }); + + it("should not call sendUserActionTelemetry for preview message when CTA button is clicked", () => { + const wrapper = mountAndCheckProps({ text: "bar" }, "preview"); + + wrapper.instance().onButtonClick(); + + assert.calledOnce(onBlockStub); + assert.notCalled(sendUserActionTelemetryStub); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx new file mode 100644 index 0000000000..12e4f96863 --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx @@ -0,0 +1,354 @@ +import { mount } from "enzyme"; +import React from "react"; +import { FluentBundle, FluentResource } from "@fluent/bundle"; +import { LocalizationProvider, ReactLocalization } from "@fluent/react"; +import { RichText } from "content-src/asrouter/components/RichText/RichText.jsx"; +import schema from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json"; +import { SubmitFormSnippet } from "content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx"; + +const DEFAULT_CONTENT = { + scene1_text: "foo", + scene2_text: "bar", + scene1_button_label: "Sign Up", + retry_button_label: "Try again", + form_action: "foo.com", + hidden_inputs: { foo: "foo" }, + error_text: "error", + success_text: "success", +}; + +describe("SubmitFormSnippet", () => { + let sandbox; + let onBlockStub; + + function mockL10nWrapper(content) { + const bundle = new FluentBundle("en-US"); + for (const [id, value] of Object.entries(content)) { + if (typeof value === "string") { + bundle.addResource(new FluentResource(`${id} = ${value}`)); + } + } + const l10n = new ReactLocalization([bundle]); + return { + wrappingComponent: LocalizationProvider, + wrappingComponentProps: { l10n }, + }; + } + + /** + * mountAndCheckProps - Mounts a SubmitFormSnippet with DEFAULT_CONTENT extended with any props + * passed in the content param and validates props against the schema. + * @param {obj} content Object containing custom message content (e.g. {text, icon, title}) + * @returns enzyme wrapper for SubmitFormSnippet + */ + function mountAndCheckProps(content = {}) { + const props = { + content: Object.assign({}, DEFAULT_CONTENT, content), + onBlock: onBlockStub, + onDismiss: sandbox.stub(), + sendUserActionTelemetry: sandbox.stub(), + onAction: sandbox.stub(), + form_method: "POST", + }; + assert.jsonSchema(props.content, schema); + return mount( + <SubmitFormSnippet {...props} />, + mockL10nWrapper(props.content) + ); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + onBlockStub = sandbox.stub(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should render .text", () => { + const wrapper = mountAndCheckProps({ scene1_text: "bar" }); + assert.equal(wrapper.find(".body").text(), "bar"); + }); + it("should not render title element if no .title prop is supplied", () => { + const wrapper = mountAndCheckProps(); + assert.lengthOf(wrapper.find(".title"), 0); + }); + it("should render .title", () => { + const wrapper = mountAndCheckProps({ scene1_title: "Foo" }); + assert.equal(wrapper.find(".title").text().trim(), "Foo"); + }); + it("should render light-theme .icon", () => { + const wrapper = mountAndCheckProps({ + scene1_icon: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-light-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render dark-theme .icon", () => { + const wrapper = mountAndCheckProps({ + scene1_icon_dark_theme: "data:image/gif;base64,R0lGODl", + }); + assert.equal( + wrapper.find(".icon-dark-theme").prop("src"), + "data:image/gif;base64,R0lGODl" + ); + }); + it("should render .button_label and default className", () => { + const wrapper = mountAndCheckProps({ scene1_button_label: "Click here" }); + + const button = wrapper.find("button.ASRouterButton"); + assert.equal(button.text(), "Click here"); + assert.equal(button.prop("className"), "ASRouterButton secondary"); + }); + + describe("#SignupView", () => { + let wrapper; + const fetchOk = { json: () => Promise.resolve({ status: "ok" }) }; + const fetchFail = { json: () => Promise.resolve({ status: "fail" }) }; + + beforeEach(() => { + wrapper = mountAndCheckProps({ + scene1_text: "bar", + scene2_email_placeholder_text: "Email", + scene2_text: "signup", + }); + }); + + it("should set the input type if provided through props.inputType", () => { + wrapper.setProps({ inputType: "number" }); + wrapper.setState({ expanded: true }); + assert.equal(wrapper.find(".mainInput").instance().type, "number"); + }); + + it("should validate via props.validateInput if provided", () => { + function validateInput(value, content) { + if (content.country === "CA" && value === "poutine") { + return ""; + } + return "Must be poutine"; + } + const setCustomValidity = sandbox.stub(); + wrapper.setProps({ + validateInput, + content: { ...DEFAULT_CONTENT, country: "CA" }, + }); + wrapper.setState({ expanded: true }); + const input = wrapper.find(".mainInput"); + input.instance().value = "poutine"; + input.simulate("change", { + target: { value: "poutine", setCustomValidity }, + }); + assert.calledWith(setCustomValidity, ""); + + input.instance().value = "fried chicken"; + input.simulate("change", { + target: { value: "fried chicken", setCustomValidity }, + }); + assert.calledWith(setCustomValidity, "Must be poutine"); + }); + + it("should show the signup form if state.expanded is true", () => { + wrapper.setState({ expanded: true }); + + assert.isTrue(wrapper.find("form").exists()); + }); + it("should dismiss the snippet", () => { + wrapper.setState({ expanded: true }); + + wrapper.find(".ASRouterButton.secondary").simulate("click"); + + assert.calledOnce(wrapper.props().onDismiss); + }); + it("should send a DISMISS event ping", () => { + wrapper.setState({ expanded: true }); + + wrapper.find(".ASRouterButton.secondary").simulate("click"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event, + "DISMISS" + ); + }); + it("should render hidden inputs + email input", () => { + wrapper.setState({ expanded: true }); + + assert.lengthOf(wrapper.find("input[type='hidden']"), 1); + }); + it("should open the SignupView when the action button is clicked", () => { + assert.isFalse(wrapper.find("form").exists()); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.isTrue(wrapper.state().expanded); + assert.isTrue(wrapper.find("form").exists()); + }); + it("should submit telemetry when the action button is clicked", () => { + assert.isFalse(wrapper.find("form").exists()); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context, + "scene1-button-learn-more" + ); + }); + it("should submit form data when submitted", () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + + wrapper.find("form").simulate("submit"); + assert.calledOnce(window.fetch); + }); + it("should send user telemetry when submitted", () => { + wrapper.setState({ expanded: true }); + + wrapper.find("form").simulate("submit"); + + assert.equal( + wrapper.props().sendUserActionTelemetry.firstCall.args[0].event_context, + "conversion-subscribe-activation" + ); + }); + it("should set signupSuccess when submission status is ok", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, true); + assert.equal(wrapper.state().signupSubmitted, true); + assert.calledOnce(onBlockStub); + assert.calledWithExactly(onBlockStub, { preventDismiss: true }); + }); + it("should send user telemetry when submission status is ok", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal( + wrapper.props().sendUserActionTelemetry.secondCall.args[0] + .event_context, + "subscribe-success" + ); + }); + it("should not block the snippet if submission failed", async () => { + sandbox.stub(window, "fetch").resolves(fetchFail); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, false); + assert.equal(wrapper.state().signupSubmitted, true); + assert.notCalled(onBlockStub); + }); + it("should not block if do_not_autoblock is true", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper = mountAndCheckProps({ + scene1_text: "bar", + scene2_email_placeholder_text: "Email", + scene2_text: "signup", + do_not_autoblock: true, + }); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal(wrapper.state().signupSuccess, true); + assert.equal(wrapper.state().signupSubmitted, true); + assert.notCalled(onBlockStub); + }); + it("should send user telemetry if submission failed", async () => { + sandbox.stub(window, "fetch").resolves(fetchFail); + wrapper.setState({ expanded: true }); + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.equal( + wrapper.props().sendUserActionTelemetry.secondCall.args[0] + .event_context, + "subscribe-error" + ); + }); + it("should render the signup success message", () => { + wrapper.setProps({ content: { success_text: "success" } }); + wrapper.setState({ signupSuccess: true, signupSubmitted: true }); + + assert.isTrue(wrapper.find(".submissionStatus").exists()); + assert.propertyVal( + wrapper.find(RichText).props(), + "localization_id", + "success_text" + ); + assert.propertyVal( + wrapper.find(RichText).props(), + "success_text", + "success" + ); + assert.isFalse(wrapper.find(".ASRouterButton").exists()); + }); + it("should render the signup error message", () => { + wrapper.setProps({ content: { error_text: "trouble" } }); + wrapper.setState({ signupSuccess: false, signupSubmitted: true }); + + assert.isTrue(wrapper.find(".submissionStatus").exists()); + assert.propertyVal( + wrapper.find(RichText).props(), + "localization_id", + "error_text" + ); + assert.propertyVal( + wrapper.find(RichText).props(), + "error_text", + "trouble" + ); + assert.isTrue(wrapper.find(".ASRouterButton").exists()); + }); + it("should render the button to return to the signup form if there was an error", () => { + wrapper.setState({ signupSubmitted: true, signupSuccess: false }); + + const button = wrapper.find("button.ASRouterButton"); + assert.equal(button.text(), "Try again"); + wrapper.find(".ASRouterButton").simulate("click"); + + assert.equal(wrapper.state().signupSubmitted, false); + }); + it("should not render the privacy notice checkbox if prop is missing", () => { + wrapper.setState({ expanded: true }); + + assert.isFalse(wrapper.find(".privacyNotice").exists()); + }); + it("should render the privacy notice checkbox if prop is provided", () => { + wrapper.setProps({ + content: { ...DEFAULT_CONTENT, scene2_privacy_html: "privacy notice" }, + }); + wrapper.setState({ expanded: true }); + + assert.isTrue(wrapper.find(".privacyNotice").exists()); + }); + it("should not call fetch if form_method is GET", async () => { + sandbox.stub(window, "fetch").resolves(fetchOk); + wrapper.setProps({ form_method: "GET" }); + wrapper.setState({ expanded: true }); + + await wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.notCalled(window.fetch); + }); + it("should block the snippet when form_method is GET", () => { + wrapper.setProps({ form_method: "GET" }); + wrapper.setState({ expanded: true }); + + wrapper.instance().handleSubmit({ preventDefault: sandbox.stub() }); + + assert.calledOnce(onBlockStub); + assert.calledWithExactly(onBlockStub, { preventDismiss: true }); + }); + it("should return to scene 2 alt when clicking the retry button", async () => { + wrapper.setState({ signupSubmitted: true }); + wrapper.setProps({ expandedAlt: true }); + + wrapper.find(".ASRouterButton").simulate("click"); + + assert.isTrue(wrapper.find(".scene2Alt").exists()); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js new file mode 100644 index 0000000000..32eaf2160e --- /dev/null +++ b/browser/components/newtab/test/unit/asrouter/templates/isEmailOrPhoneNumber.test.js @@ -0,0 +1,56 @@ +import { isEmailOrPhoneNumber } from "content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber"; + +const CONTENT = {}; + +describe("isEmailOrPhoneNumber", () => { + it("should return 'email' for emails", () => { + assert.equal(isEmailOrPhoneNumber("foobar@asd.com", CONTENT), "email"); + assert.equal(isEmailOrPhoneNumber("foobar@asd.co.uk", CONTENT), "email"); + }); + it("should return 'phone' for valid en-US/en-CA phone numbers", () => { + assert.equal( + isEmailOrPhoneNumber("14582731273", { locale: "en-US" }), + "phone" + ); + assert.equal( + isEmailOrPhoneNumber("4582731273", { locale: "en-CA" }), + "phone" + ); + }); + it("should return an empty string for invalid phone number lengths in en-US/en-CA", () => { + // Not enough digits + assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-US" }), ""); + assert.equal(isEmailOrPhoneNumber("4522", { locale: "en-CA" }), ""); + }); + it("should return 'phone' for valid German phone numbers", () => { + assert.equal( + isEmailOrPhoneNumber("145827312732", { locale: "de" }), + "phone" + ); + }); + it("should return 'phone' for any number of digits in other locales", () => { + assert.equal(isEmailOrPhoneNumber("4", CONTENT), "phone"); + }); + it("should return an empty string for other invalid inputs", () => { + assert.equal( + isEmailOrPhoneNumber("abc", CONTENT), + "", + "abc should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("abc@", CONTENT), + "", + "abc@ should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("abc@foo", CONTENT), + "", + "abc@foo should be invalid" + ); + assert.equal( + isEmailOrPhoneNumber("123d1232", CONTENT), + "", + "123d1232 should be invalid" + ); + }); +}); diff --git a/browser/components/newtab/test/unit/common/Actions.test.js b/browser/components/newtab/test/unit/common/Actions.test.js new file mode 100644 index 0000000000..32e417ea3f --- /dev/null +++ b/browser/components/newtab/test/unit/common/Actions.test.js @@ -0,0 +1,236 @@ +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, + BACKGROUND_PROCESS, + CONTENT_MESSAGE_TYPE, + globalImportContext, + MAIN_MESSAGE_TYPE, + PRELOAD_MESSAGE_TYPE, + UI_CODE, +} from "common/Actions.sys.mjs"; + +describe("Actions", () => { + it("should set globalImportContext to UI_CODE", () => { + assert.equal(globalImportContext, UI_CODE); + }); +}); + +describe("ActionTypes", () => { + it("should be in alpha order", () => { + assert.equal(Object.keys(at).join(", "), Object.keys(at).sort().join(", ")); + }); +}); + +describe("ActionCreators", () => { + describe("_RouteMessage", () => { + it("should throw if options are not passed as the second param", () => { + assert.throws(() => { + au._RouteMessage({ type: "FOO" }); + }); + }); + it("should set all defined options on the .meta property of the new action", () => { + assert.deepEqual( + au._RouteMessage( + { type: "FOO", meta: { hello: "world" } }, + { from: "foo", to: "bar" } + ), + { type: "FOO", meta: { hello: "world", from: "foo", to: "bar" } } + ); + }); + it("should remove any undefined options related to message routing", () => { + const action = au._RouteMessage( + { type: "FOO", meta: { fromTarget: "bar" } }, + { from: "foo", to: "bar" } + ); + assert.isUndefined(action.meta.fromTarget); + }); + }); + describe("AlsoToMain", () => { + it("should create the right action", () => { + const action = { type: "FOO", data: "BAR" }; + const newAction = ac.AlsoToMain(action); + assert.deepEqual(newAction, { + type: "FOO", + data: "BAR", + meta: { from: CONTENT_MESSAGE_TYPE, to: MAIN_MESSAGE_TYPE }, + }); + }); + it("should add the fromTarget if it was supplied", () => { + const action = { type: "FOO", data: "BAR" }; + const newAction = ac.AlsoToMain(action, "port123"); + assert.equal(newAction.meta.fromTarget, "port123"); + }); + describe("isSendToMain", () => { + it("should return true if action is AlsoToMain", () => { + const newAction = ac.AlsoToMain({ type: "FOO" }); + assert.isTrue(au.isSendToMain(newAction)); + }); + it("should return false if action is not AlsoToMain", () => { + assert.isFalse(au.isSendToMain({ type: "FOO" })); + }); + }); + }); + describe("AlsoToOneContent", () => { + it("should create the right action", () => { + const action = { type: "FOO", data: "BAR" }; + const targetId = "abc123"; + const newAction = ac.AlsoToOneContent(action, targetId); + assert.deepEqual(newAction, { + type: "FOO", + data: "BAR", + meta: { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + toTarget: targetId, + }, + }); + }); + it("should throw if no targetId is provided", () => { + assert.throws(() => { + ac.AlsoToOneContent({ type: "FOO" }); + }); + }); + describe("isSendToOneContent", () => { + it("should return true if action is AlsoToOneContent", () => { + const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123"); + assert.isTrue(au.isSendToOneContent(newAction)); + }); + it("should return false if action is not AlsoToMain", () => { + assert.isFalse(au.isSendToOneContent({ type: "FOO" })); + assert.isFalse( + au.isSendToOneContent(ac.BroadcastToContent({ type: "FOO" })) + ); + }); + }); + describe("isFromMain", () => { + it("should return true if action is AlsoToOneContent", () => { + const newAction = ac.AlsoToOneContent({ type: "FOO" }, "foo123"); + assert.isTrue(au.isFromMain(newAction)); + }); + it("should return true if action is BroadcastToContent", () => { + const newAction = ac.BroadcastToContent({ type: "FOO" }); + assert.isTrue(au.isFromMain(newAction)); + }); + it("should return false if action is AlsoToMain", () => { + const newAction = ac.AlsoToMain({ type: "FOO" }); + assert.isFalse(au.isFromMain(newAction)); + }); + }); + }); + describe("BroadcastToContent", () => { + it("should create the right action", () => { + const action = { type: "FOO", data: "BAR" }; + const newAction = ac.BroadcastToContent(action); + assert.deepEqual(newAction, { + type: "FOO", + data: "BAR", + meta: { from: MAIN_MESSAGE_TYPE, to: CONTENT_MESSAGE_TYPE }, + }); + }); + describe("isBroadcastToContent", () => { + it("should return true if action is BroadcastToContent", () => { + assert.isTrue( + au.isBroadcastToContent(ac.BroadcastToContent({ type: "FOO" })) + ); + }); + it("should return false if action is not BroadcastToContent", () => { + assert.isFalse(au.isBroadcastToContent({ type: "FOO" })); + assert.isFalse( + au.isBroadcastToContent( + ac.AlsoToOneContent({ type: "FOO" }, "foo123") + ) + ); + }); + }); + }); + describe("AlsoToPreloaded", () => { + it("should create the right action", () => { + const action = { type: "FOO", data: "BAR" }; + const newAction = ac.AlsoToPreloaded(action); + assert.deepEqual(newAction, { + type: "FOO", + data: "BAR", + meta: { from: MAIN_MESSAGE_TYPE, to: PRELOAD_MESSAGE_TYPE }, + }); + }); + }); + describe("isSendToPreloaded", () => { + it("should return true if action is AlsoToPreloaded", () => { + assert.isTrue(au.isSendToPreloaded(ac.AlsoToPreloaded({ type: "FOO" }))); + }); + it("should return false if action is not AlsoToPreloaded", () => { + assert.isFalse(au.isSendToPreloaded({ type: "FOO" })); + assert.isFalse( + au.isSendToPreloaded(ac.BroadcastToContent({ type: "FOO" })) + ); + }); + }); + describe("UserEvent", () => { + it("should include the given data", () => { + const data = { action: "foo" }; + assert.equal(ac.UserEvent(data).data, data); + }); + it("should wrap with AlsoToMain", () => { + const action = ac.UserEvent({ action: "foo" }); + assert.isTrue(au.isSendToMain(action), "isSendToMain"); + }); + }); + describe("ASRouterUserEvent", () => { + it("should include the given data", () => { + const data = { action: "foo" }; + assert.equal(ac.ASRouterUserEvent(data).data, data); + }); + it("should wrap with AlsoToMain", () => { + const action = ac.ASRouterUserEvent({ action: "foo" }); + assert.isTrue(au.isSendToMain(action), "isSendToMain"); + }); + }); + describe("ImpressionStats", () => { + it("should include the right data", () => { + const data = { action: "foo" }; + assert.equal(ac.ImpressionStats(data).data, data); + }); + it("should wrap with AlsoToMain if in UI code", () => { + assert.isTrue( + au.isSendToMain(ac.ImpressionStats({ action: "foo" })), + "isSendToMain" + ); + }); + it("should not wrap with AlsoToMain if not in UI code", () => { + const action = ac.ImpressionStats({ action: "foo" }, BACKGROUND_PROCESS); + assert.isFalse(au.isSendToMain(action), "isSendToMain"); + }); + }); + describe("WebExtEvent", () => { + it("should set the provided type", () => { + const action = ac.WebExtEvent(at.WEBEXT_CLICK, { + source: "MyExtension", + url: "foo.com", + }); + assert.equal(action.type, at.WEBEXT_CLICK); + }); + it("should set the provided data", () => { + const data = { source: "MyExtension", url: "foo.com" }; + const action = ac.WebExtEvent(at.WEBEXT_CLICK, data); + assert.equal(action.data, data); + }); + it("should throw if the 'source' property is missing", () => { + assert.throws(() => { + ac.WebExtEvent(at.WEBEXT_CLICK, {}); + }); + }); + }); +}); + +describe("ActionUtils", () => { + describe("getPortIdOfSender", () => { + it("should return the PortID from a AlsoToMain action", () => { + const portID = "foo123"; + const result = au.getPortIdOfSender( + ac.AlsoToMain({ type: "FOO" }, portID) + ); + assert.equal(result, portID); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/common/Dedupe.test.js b/browser/components/newtab/test/unit/common/Dedupe.test.js new file mode 100644 index 0000000000..1c85eafa50 --- /dev/null +++ b/browser/components/newtab/test/unit/common/Dedupe.test.js @@ -0,0 +1,38 @@ +import { Dedupe } from "common/Dedupe.sys.mjs"; + +describe("Dedupe", () => { + let instance; + beforeEach(() => { + instance = new Dedupe(); + }); + describe("group", () => { + it("should remove duplicates inside the groups", () => { + const beforeItems = [ + [1, 1, 1], + [2, 2, 2], + [3, 3, 3], + ]; + const afterItems = [[1], [2], [3]]; + assert.deepEqual(instance.group(...beforeItems), afterItems); + }); + it("should remove duplicates between groups, favouring earlier groups", () => { + const beforeItems = [ + [1, 2, 3], + [2, 3, 4], + [3, 4, 5], + ]; + const afterItems = [[1, 2, 3], [4], [5]]; + assert.deepEqual(instance.group(...beforeItems), afterItems); + }); + it("should remove duplicates from groups of objects", () => { + instance = new Dedupe(item => item.id); + const beforeItems = [ + [{ id: 1 }, { id: 1 }, { id: 2 }], + [{ id: 1 }, { id: 3 }, { id: 2 }], + [{ id: 1 }, { id: 2 }, { id: 5 }], + ]; + const afterItems = [[{ id: 1 }, { id: 2 }], [{ id: 3 }], [{ id: 5 }]]; + assert.deepEqual(instance.group(...beforeItems), afterItems); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/common/Reducers.test.js b/browser/components/newtab/test/unit/common/Reducers.test.js new file mode 100644 index 0000000000..ab2a5b4d62 --- /dev/null +++ b/browser/components/newtab/test/unit/common/Reducers.test.js @@ -0,0 +1,1566 @@ +import { INITIAL_STATE, insertPinned, reducers } from "common/Reducers.sys.mjs"; +const { + TopSites, + App, + Snippets, + Prefs, + Dialog, + Sections, + Pocket, + Personalization, + DiscoveryStream, + Search, + ASRouter, +} = reducers; +import { actionTypes as at } from "common/Actions.sys.mjs"; + +describe("Reducers", () => { + describe("App", () => { + it("should return the initial state", () => { + const nextState = App(undefined, { type: "FOO" }); + assert.equal(nextState, INITIAL_STATE.App); + }); + it("should set initialized to true on INIT", () => { + const nextState = App(undefined, { type: "INIT" }); + + assert.propertyVal(nextState, "initialized", true); + }); + }); + describe("TopSites", () => { + it("should return the initial state", () => { + const nextState = TopSites(undefined, { type: "FOO" }); + assert.equal(nextState, INITIAL_STATE.TopSites); + }); + it("should add top sites on TOP_SITES_UPDATED", () => { + const newRows = [{ url: "foo.com" }, { url: "bar.com" }]; + const nextState = TopSites(undefined, { + type: at.TOP_SITES_UPDATED, + data: { links: newRows }, + }); + assert.equal(nextState.rows, newRows); + }); + it("should not update state for empty action.data on TOP_SITES_UPDATED", () => { + const nextState = TopSites(undefined, { type: at.TOP_SITES_UPDATED }); + assert.equal(nextState, INITIAL_STATE.TopSites); + }); + it("should initialize prefs on TOP_SITES_UPDATED", () => { + const nextState = TopSites(undefined, { + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: "foo" }, + }); + + assert.equal(nextState.pref, "foo"); + }); + it("should pass prevState.prefs if not present in TOP_SITES_UPDATED", () => { + const nextState = TopSites( + { prefs: "foo" }, + { type: at.TOP_SITES_UPDATED, data: { links: [] } } + ); + + assert.equal(nextState.prefs, "foo"); + }); + it("should set editForm.site to action.data on TOP_SITES_EDIT", () => { + const data = { index: 7 }; + const nextState = TopSites(undefined, { type: at.TOP_SITES_EDIT, data }); + assert.equal(nextState.editForm.index, data.index); + }); + it("should set editForm to null on TOP_SITES_CANCEL_EDIT", () => { + const nextState = TopSites(undefined, { type: at.TOP_SITES_CANCEL_EDIT }); + assert.isNull(nextState.editForm); + }); + it("should preserve the editForm.index", () => { + const actionTypes = [ + at.PREVIEW_RESPONSE, + at.PREVIEW_REQUEST, + at.PREVIEW_REQUEST_CANCEL, + ]; + actionTypes.forEach(type => { + const oldState = { editForm: { index: 0, previewUrl: "foo" } }; + const action = { type, data: { url: "foo" } }; + const nextState = TopSites(oldState, action); + assert.equal(nextState.editForm.index, 0); + }); + }); + it("should set previewResponse on PREVIEW_RESPONSE", () => { + const oldState = { editForm: { previewUrl: "url" } }; + const action = { + type: at.PREVIEW_RESPONSE, + data: { preview: "data:123", url: "url" }, + }; + const nextState = TopSites(oldState, action); + assert.propertyVal(nextState.editForm, "previewResponse", "data:123"); + }); + it("should return previous state if action url does not match expected", () => { + const oldState = { editForm: { previewUrl: "foo" } }; + const action = { type: at.PREVIEW_RESPONSE, data: { url: "bar" } }; + const nextState = TopSites(oldState, action); + assert.equal(nextState, oldState); + }); + it("should return previous state if editForm is not set", () => { + const actionTypes = [ + at.PREVIEW_RESPONSE, + at.PREVIEW_REQUEST, + at.PREVIEW_REQUEST_CANCEL, + ]; + actionTypes.forEach(type => { + const oldState = { editForm: null }; + const action = { type, data: { url: "bar" } }; + const nextState = TopSites(oldState, action); + assert.equal(nextState, oldState, type); + }); + }); + it("should set previewResponse to null on PREVIEW_REQUEST", () => { + const oldState = { editForm: { previewResponse: "foo" } }; + const action = { type: at.PREVIEW_REQUEST, data: {} }; + const nextState = TopSites(oldState, action); + assert.propertyVal(nextState.editForm, "previewResponse", null); + }); + it("should set previewUrl on PREVIEW_REQUEST", () => { + const oldState = { editForm: {} }; + const action = { type: at.PREVIEW_REQUEST, data: { url: "bar" } }; + const nextState = TopSites(oldState, action); + assert.propertyVal(nextState.editForm, "previewUrl", "bar"); + }); + it("should add screenshots for SCREENSHOT_UPDATED", () => { + const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] }; + const action = { + type: at.SCREENSHOT_UPDATED, + data: { url: "bar.com", screenshot: "data:123" }, + }; + const nextState = TopSites(oldState, action); + assert.deepEqual(nextState.rows, [ + { url: "foo.com" }, + { url: "bar.com", screenshot: "data:123" }, + ]); + }); + it("should not modify rows if nothing matches the url for SCREENSHOT_UPDATED", () => { + const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] }; + const action = { + type: at.SCREENSHOT_UPDATED, + data: { url: "baz.com", screenshot: "data:123" }, + }; + const nextState = TopSites(oldState, action); + assert.deepEqual(nextState, oldState); + }); + it("should bookmark an item on PLACES_BOOKMARK_ADDED", () => { + const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] }; + const action = { + type: at.PLACES_BOOKMARK_ADDED, + data: { + url: "bar.com", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + dateAdded: 1234567, + }, + }; + const nextState = TopSites(oldState, action); + const [, newRow] = nextState.rows; + // new row has bookmark data + assert.equal(newRow.url, action.data.url); + assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid); + assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle); + assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded); + + // old row is unchanged + assert.equal(nextState.rows[0], oldState.rows[0]); + }); + it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => { + const nextState = TopSites(undefined, { type: at.PLACES_BOOKMARK_ADDED }); + assert.equal(nextState, INITIAL_STATE.TopSites); + }); + it("should remove a bookmark on PLACES_BOOKMARKS_REMOVED", () => { + const oldState = { + rows: [ + { url: "foo.com" }, + { + url: "bar.com", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + dateAdded: 123456, + }, + ], + }; + const action = { + type: at.PLACES_BOOKMARKS_REMOVED, + data: { urls: ["bar.com"] }, + }; + const nextState = TopSites(oldState, action); + const [, newRow] = nextState.rows; + // new row no longer has bookmark data + assert.equal(newRow.url, oldState.rows[1].url); + assert.isUndefined(newRow.bookmarkGuid); + assert.isUndefined(newRow.bookmarkTitle); + assert.isUndefined(newRow.bookmarkDateCreated); + + // old row is unchanged + assert.deepEqual(nextState.rows[0], oldState.rows[0]); + }); + it("should not update state for empty action.data on PLACES_BOOKMARKS_REMOVED", () => { + const nextState = TopSites(undefined, { + type: at.PLACES_BOOKMARKS_REMOVED, + }); + assert.equal(nextState, INITIAL_STATE.TopSites); + }); + it("should update prefs on TOP_SITES_PREFS_UPDATED", () => { + const state = TopSites( + {}, + { type: at.TOP_SITES_PREFS_UPDATED, data: { pref: "foo" } } + ); + + assert.equal(state.pref, "foo"); + }); + it("should not update state for empty action.data on PLACES_LINKS_DELETED", () => { + const nextState = TopSites(undefined, { type: at.PLACES_LINKS_DELETED }); + assert.equal(nextState, INITIAL_STATE.TopSites); + }); + it("should remove the site on PLACES_LINKS_DELETED", () => { + const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] }; + const deleteAction = { + type: at.PLACES_LINKS_DELETED, + data: { urls: ["foo.com"] }, + }; + const nextState = TopSites(oldState, deleteAction); + assert.deepEqual(nextState.rows, [{ url: "bar.com" }]); + }); + it("should set showSearchShortcutsForm to true on TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL", () => { + const data = { index: 7 }; + const nextState = TopSites(undefined, { + type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL, + data, + }); + assert.isTrue(nextState.showSearchShortcutsForm); + }); + it("should set showSearchShortcutsForm to false on TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL", () => { + const nextState = TopSites(undefined, { + type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL, + }); + assert.isFalse(nextState.showSearchShortcutsForm); + }); + it("should update searchShortcuts on UPDATE_SEARCH_SHORTCUTS", () => { + const shortcuts = [ + { + keyword: "@google", + shortURL: "google", + url: "https://google.com", + searchIdentifier: /^google/, + }, + { + keyword: "@baidu", + shortURL: "baidu", + url: "https://baidu.com", + searchIdentifier: /^baidu/, + }, + ]; + const nextState = TopSites(undefined, { + type: at.UPDATE_SEARCH_SHORTCUTS, + data: { searchShortcuts: shortcuts }, + }); + assert.deepEqual(shortcuts, nextState.searchShortcuts); + }); + it("should remove all content on SNIPPETS_PREVIEW_MODE", () => { + const oldState = { rows: [{ url: "foo.com" }, { url: "bar.com" }] }; + const nextState = TopSites(oldState, { type: at.SNIPPETS_PREVIEW_MODE }); + assert.lengthOf(nextState.rows, 0); + }); + }); + describe("Prefs", () => { + function prevState(custom = {}) { + return Object.assign({}, INITIAL_STATE.Prefs, custom); + } + it("should have the correct initial state", () => { + const state = Prefs(undefined, {}); + assert.deepEqual(state, INITIAL_STATE.Prefs); + }); + describe("PREFS_INITIAL_VALUES", () => { + it("should return a new object", () => { + const state = Prefs(undefined, { + type: at.PREFS_INITIAL_VALUES, + data: {}, + }); + assert.notEqual( + INITIAL_STATE.Prefs, + state, + "should not modify INITIAL_STATE" + ); + }); + it("should set initalized to true", () => { + const state = Prefs(undefined, { + type: at.PREFS_INITIAL_VALUES, + data: {}, + }); + assert.isTrue(state.initialized); + }); + it("should set .values", () => { + const newValues = { foo: 1, bar: 2 }; + const state = Prefs(undefined, { + type: at.PREFS_INITIAL_VALUES, + data: newValues, + }); + assert.equal(state.values, newValues); + }); + }); + describe("PREF_CHANGED", () => { + it("should return a new Prefs object", () => { + const state = Prefs(undefined, { + type: at.PREF_CHANGED, + data: { name: "foo", value: 2 }, + }); + assert.notEqual( + INITIAL_STATE.Prefs, + state, + "should not modify INITIAL_STATE" + ); + }); + it("should set the changed pref", () => { + const state = Prefs(prevState({ foo: 1 }), { + type: at.PREF_CHANGED, + data: { name: "foo", value: 2 }, + }); + assert.equal(state.values.foo, 2); + }); + it("should return a new .pref object instead of mutating", () => { + const oldState = prevState({ foo: 1 }); + const state = Prefs(oldState, { + type: at.PREF_CHANGED, + data: { name: "foo", value: 2 }, + }); + assert.notEqual(oldState.values, state.values); + }); + }); + }); + describe("Dialog", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + INITIAL_STATE.Dialog, + Dialog(undefined, { type: "non_existent" }) + ); + }); + it("should toggle visible to true on DIALOG_OPEN", () => { + const action = { type: at.DIALOG_OPEN }; + const nextState = Dialog(INITIAL_STATE.Dialog, action); + assert.isTrue(nextState.visible); + }); + it("should pass url data on DIALOG_OPEN", () => { + const action = { type: at.DIALOG_OPEN, data: "some url" }; + const nextState = Dialog(INITIAL_STATE.Dialog, action); + assert.equal(nextState.data, action.data); + }); + it("should toggle visible to false on DIALOG_CANCEL", () => { + const action = { type: at.DIALOG_CANCEL, data: "some url" }; + const nextState = Dialog(INITIAL_STATE.Dialog, action); + assert.isFalse(nextState.visible); + }); + it("should return inital state on DELETE_HISTORY_URL", () => { + const action = { type: at.DELETE_HISTORY_URL }; + const nextState = Dialog(INITIAL_STATE.Dialog, action); + + assert.deepEqual(INITIAL_STATE.Dialog, nextState); + }); + }); + describe("Sections", () => { + let oldState; + + beforeEach(() => { + oldState = new Array(5).fill(null).map((v, i) => ({ + id: `foo_bar_${i}`, + title: `Foo Bar ${i}`, + initialized: false, + rows: [ + { url: "www.foo.bar", pocket_id: 123 }, + { url: "www.other.url" }, + ], + order: i, + type: "history", + })); + }); + + it("should return INITIAL_STATE by default", () => { + assert.equal( + INITIAL_STATE.Sections, + Sections(undefined, { type: "non_existent" }) + ); + }); + it("should remove the correct section on SECTION_DEREGISTER", () => { + const newState = Sections(oldState, { + type: at.SECTION_DEREGISTER, + data: "foo_bar_2", + }); + assert.lengthOf(newState, 4); + const expectedNewState = oldState.splice(2, 1) && oldState; + assert.deepEqual(newState, expectedNewState); + }); + it("should add a section on SECTION_REGISTER if it doesn't already exist", () => { + const action = { + type: at.SECTION_REGISTER, + data: { id: "foo_bar_5", title: "Foo Bar 5" }, + }; + const newState = Sections(oldState, action); + assert.lengthOf(newState, 6); + const insertedSection = newState.find( + section => section.id === "foo_bar_5" + ); + assert.propertyVal(insertedSection, "title", action.data.title); + }); + it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => { + const action = { + type: at.SECTION_REGISTER, + data: { id: "foo_bar_5", title: "Foo Bar 5" }, + }; + const newState = Sections(oldState, action); + const insertedSection = newState.find( + section => section.id === "foo_bar_5" + ); + assert.deepEqual(insertedSection.rows, []); + }); + it("should update a section on SECTION_REGISTER if it already exists", () => { + const NEW_TITLE = "New Title"; + const action = { + type: at.SECTION_REGISTER, + data: { id: "foo_bar_2", title: NEW_TITLE }, + }; + const newState = Sections(oldState, action); + assert.lengthOf(newState, 5); + const updatedSection = newState.find( + section => section.id === "foo_bar_2" + ); + assert.ok(updatedSection && updatedSection.title === NEW_TITLE); + }); + it("should set initialized to false on SECTION_REGISTER if there are no rows", () => { + const NEW_TITLE = "New Title"; + const action = { + type: at.SECTION_REGISTER, + data: { id: "bloop", title: NEW_TITLE }, + }; + const newState = Sections(oldState, action); + const updatedSection = newState.find(section => section.id === "bloop"); + assert.propertyVal(updatedSection, "initialized", false); + }); + it("should set initialized to true on SECTION_REGISTER if there are rows", () => { + const NEW_TITLE = "New Title"; + const action = { + type: at.SECTION_REGISTER, + data: { id: "bloop", title: NEW_TITLE, rows: [{}, {}] }, + }; + const newState = Sections(oldState, action); + const updatedSection = newState.find(section => section.id === "bloop"); + assert.propertyVal(updatedSection, "initialized", true); + }); + it("should have no effect on SECTION_UPDATE if the id doesn't exist", () => { + const action = { + type: at.SECTION_UPDATE, + data: { id: "fake_id", data: "fake_data" }, + }; + const newState = Sections(oldState, action); + assert.deepEqual(oldState, newState); + }); + it("should update the section with the correct data on SECTION_UPDATE", () => { + const FAKE_DATA = { rows: ["some", "fake", "data"], foo: "bar" }; + const action = { + type: at.SECTION_UPDATE, + data: Object.assign(FAKE_DATA, { id: "foo_bar_2" }), + }; + const newState = Sections(oldState, action); + const updatedSection = newState.find( + section => section.id === "foo_bar_2" + ); + assert.include(updatedSection, FAKE_DATA); + }); + it("should set initialized to true on SECTION_UPDATE if rows is defined on action.data", () => { + const data = { rows: [], id: "foo_bar_2" }; + const action = { type: at.SECTION_UPDATE, data }; + const newState = Sections(oldState, action); + const updatedSection = newState.find( + section => section.id === "foo_bar_2" + ); + assert.propertyVal(updatedSection, "initialized", true); + }); + it("should retain pinned cards on SECTION_UPDATE", () => { + const ROW = { id: "row" }; + let newState = Sections(oldState, { + type: at.SECTION_UPDATE, + data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }), + }); + let updatedSection = newState.find(section => section.id === "foo_bar_2"); + assert.deepEqual(updatedSection.rows, [ROW]); + + const PINNED_ROW = { id: "pinned", pinned: true, guid: "pinned" }; + newState = Sections(newState, { + type: at.SECTION_UPDATE, + data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }), + }); + updatedSection = newState.find(section => section.id === "foo_bar_2"); + assert.deepEqual(updatedSection.rows, [PINNED_ROW]); + + // Updating the section again should not duplicate pinned cards + newState = Sections(newState, { + type: at.SECTION_UPDATE, + data: Object.assign({ rows: [PINNED_ROW] }, { id: "foo_bar_2" }), + }); + updatedSection = newState.find(section => section.id === "foo_bar_2"); + assert.deepEqual(updatedSection.rows, [PINNED_ROW]); + + // Updating the section should retain pinned card at its index + newState = Sections(newState, { + type: at.SECTION_UPDATE, + data: Object.assign({ rows: [ROW] }, { id: "foo_bar_2" }), + }); + updatedSection = newState.find(section => section.id === "foo_bar_2"); + assert.deepEqual(updatedSection.rows, [PINNED_ROW, ROW]); + + // Clearing/Resetting the section should clear pinned cards + newState = Sections(newState, { + type: at.SECTION_UPDATE, + data: Object.assign({ rows: [] }, { id: "foo_bar_2" }), + }); + updatedSection = newState.find(section => section.id === "foo_bar_2"); + assert.deepEqual(updatedSection.rows, []); + }); + it("should have no effect on SECTION_UPDATE_CARD if the id or url doesn't exist", () => { + const noIdAction = { + type: at.SECTION_UPDATE_CARD, + data: { + id: "non-existent", + url: "www.foo.bar", + options: { title: "New title" }, + }, + }; + const noIdState = Sections(oldState, noIdAction); + const noUrlAction = { + type: at.SECTION_UPDATE_CARD, + data: { + id: "foo_bar_2", + url: "www.non-existent.url", + options: { title: "New title" }, + }, + }; + const noUrlState = Sections(oldState, noUrlAction); + assert.deepEqual(noIdState, oldState); + assert.deepEqual(noUrlState, oldState); + }); + it("should update the card with the correct data on SECTION_UPDATE_CARD", () => { + const action = { + type: at.SECTION_UPDATE_CARD, + data: { + id: "foo_bar_2", + url: "www.other.url", + options: { title: "Fake new title" }, + }, + }; + const newState = Sections(oldState, action); + const updatedSection = newState.find( + section => section.id === "foo_bar_2" + ); + const updatedCard = updatedSection.rows.find( + card => card.url === "www.other.url" + ); + assert.propertyVal(updatedCard, "title", "Fake new title"); + }); + it("should only update the cards belonging to the right section on SECTION_UPDATE_CARD", () => { + const action = { + type: at.SECTION_UPDATE_CARD, + data: { + id: "foo_bar_2", + url: "www.other.url", + options: { title: "Fake new title" }, + }, + }; + const newState = Sections(oldState, action); + newState.forEach((section, i) => { + if (section.id !== "foo_bar_2") { + assert.deepEqual(section, oldState[i]); + } + }); + }); + it("should allow action.data to set .initialized", () => { + const data = { rows: [], initialized: false, id: "foo_bar_2" }; + const action = { type: at.SECTION_UPDATE, data }; + const newState = Sections(oldState, action); + const updatedSection = newState.find( + section => section.id === "foo_bar_2" + ); + assert.propertyVal(updatedSection, "initialized", false); + }); + it("should dedupe based on dedupeConfigurations", () => { + const site = { url: "foo.com" }; + const highlights = { rows: [site], id: "highlights" }; + const topstories = { rows: [site], id: "topstories" }; + const dedupeConfigurations = [ + { id: "topstories", dedupeFrom: ["highlights"] }, + ]; + const action = { data: { dedupeConfigurations }, type: "SECTION_UPDATE" }; + const state = [highlights, topstories]; + + const nextState = Sections(state, action); + + assert.equal(nextState.find(s => s.id === "highlights").rows.length, 1); + assert.equal(nextState.find(s => s.id === "topstories").rows.length, 0); + }); + it("should remove blocked and deleted urls from all rows in all sections", () => { + const blockAction = { + type: at.PLACES_LINK_BLOCKED, + data: { url: "www.foo.bar" }, + }; + const deleteAction = { + type: at.PLACES_LINKS_DELETED, + data: { urls: ["www.foo.bar"] }, + }; + const newBlockState = Sections(oldState, blockAction); + const newDeleteState = Sections(oldState, deleteAction); + newBlockState.concat(newDeleteState).forEach(section => { + assert.deepEqual(section.rows, [{ url: "www.other.url" }]); + }); + }); + it("should not update state for empty action.data on PLACES_LINK_BLOCKED", () => { + const nextState = Sections(undefined, { type: at.PLACES_LINK_BLOCKED }); + assert.equal(nextState, INITIAL_STATE.Sections); + }); + it("should not update state for empty action.data on PLACES_LINKS_DELETED", () => { + const nextState = Sections(undefined, { type: at.PLACES_LINKS_DELETED }); + assert.equal(nextState, INITIAL_STATE.Sections); + }); + it("should remove all removed pocket urls", () => { + const removeAction = { + type: at.DELETE_FROM_POCKET, + data: { pocket_id: 123 }, + }; + const newBlockState = Sections(oldState, removeAction); + newBlockState.forEach(section => { + assert.deepEqual(section.rows, [{ url: "www.other.url" }]); + }); + }); + it("should archive all archived pocket urls", () => { + const removeAction = { + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: 123 }, + }; + const newBlockState = Sections(oldState, removeAction); + newBlockState.forEach(section => { + assert.deepEqual(section.rows, [{ url: "www.other.url" }]); + }); + }); + it("should not update state for empty action.data on PLACES_BOOKMARK_ADDED", () => { + const nextState = Sections(undefined, { type: at.PLACES_BOOKMARK_ADDED }); + assert.equal(nextState, INITIAL_STATE.Sections); + }); + it("should bookmark an item when PLACES_BOOKMARK_ADDED is received", () => { + const action = { + type: at.PLACES_BOOKMARK_ADDED, + data: { + url: "www.foo.bar", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + dateAdded: 1234567, + }, + }; + const nextState = Sections(oldState, action); + // check a section to ensure the correct url was bookmarked + const [newRow, oldRow] = nextState[0].rows; + + // new row has bookmark data + assert.equal(newRow.url, action.data.url); + assert.equal(newRow.type, "bookmark"); + assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid); + assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle); + assert.equal(newRow.bookmarkDateCreated, action.data.dateAdded); + + // old row is unchanged + assert.equal(oldRow, oldState[0].rows[1]); + }); + it("should not update state for empty action.data on PLACES_BOOKMARKS_REMOVED", () => { + const nextState = Sections(undefined, { + type: at.PLACES_BOOKMARKS_REMOVED, + }); + assert.equal(nextState, INITIAL_STATE.Sections); + }); + it("should remove the bookmark when PLACES_BOOKMARKS_REMOVED is received", () => { + const action = { + type: at.PLACES_BOOKMARKS_REMOVED, + data: { + urls: ["www.foo.bar"], + bookmarkGuid: "bookmark123", + }, + }; + // add some bookmark data for the first url in rows + oldState.forEach(item => { + item.rows[0].bookmarkGuid = "bookmark123"; + item.rows[0].bookmarkTitle = "Title for bar.com"; + item.rows[0].bookmarkDateCreated = 1234567; + item.rows[0].type = "bookmark"; + }); + const nextState = Sections(oldState, action); + // check a section to ensure the correct bookmark was removed + const [newRow, oldRow] = nextState[0].rows; + + // new row isn't a bookmark + assert.equal(newRow.url, action.data.urls[0]); + assert.equal(newRow.type, "history"); + assert.isUndefined(newRow.bookmarkGuid); + assert.isUndefined(newRow.bookmarkTitle); + assert.isUndefined(newRow.bookmarkDateCreated); + + // old row is unchanged + assert.equal(oldRow, oldState[0].rows[1]); + }); + it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => { + const nextState = Sections(undefined, { + type: at.PLACES_SAVED_TO_POCKET, + }); + assert.equal(nextState, INITIAL_STATE.Sections); + }); + it("should add a pocked item on PLACES_SAVED_TO_POCKET", () => { + const action = { + type: at.PLACES_SAVED_TO_POCKET, + data: { + url: "www.foo.bar", + pocket_id: 1234, + title: "Title for bar.com", + }, + }; + const nextState = Sections(oldState, action); + // check a section to ensure the correct url was saved to pocket + const [newRow, oldRow] = nextState[0].rows; + + // new row has pocket data + assert.equal(newRow.url, action.data.url); + assert.equal(newRow.type, "pocket"); + assert.equal(newRow.pocket_id, action.data.pocket_id); + assert.equal(newRow.title, action.data.title); + + // old row is unchanged + assert.equal(oldRow, oldState[0].rows[1]); + }); + it("should remove all content on SNIPPETS_PREVIEW_MODE", () => { + const previewMode = { type: at.SNIPPETS_PREVIEW_MODE }; + const newState = Sections(oldState, previewMode); + newState.forEach(section => { + assert.lengthOf(section.rows, 0); + }); + }); + }); + describe("#insertPinned", () => { + let links; + + beforeEach(() => { + links = new Array(12).fill(null).map((v, i) => ({ url: `site${i}.com` })); + }); + + it("should place pinned links where they belong", () => { + const pinned = [ + { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" }, + { url: "http://example.com", title: "example" }, + ]; + const result = insertPinned(links, pinned); + for (let index of [0, 1]) { + assert.equal(result[index].url, pinned[index].url); + assert.ok(result[index].isPinned); + assert.equal(result[index].pinIndex, index); + } + assert.deepEqual(result.slice(2), links); + }); + it("should handle empty slots in the pinned list", () => { + const pinned = [ + null, + { url: "http://github.com/mozilla/activity-stream", title: "moz/a-s" }, + null, + null, + { url: "http://example.com", title: "example" }, + ]; + const result = insertPinned(links, pinned); + for (let index of [1, 4]) { + assert.equal(result[index].url, pinned[index].url); + assert.ok(result[index].isPinned); + assert.equal(result[index].pinIndex, index); + } + result.splice(4, 1); + result.splice(1, 1); + assert.deepEqual(result, links); + }); + it("should handle a pinned site past the end of the list of links", () => { + const pinned = []; + pinned[11] = { + url: "http://github.com/mozilla/activity-stream", + title: "moz/a-s", + }; + const result = insertPinned([], pinned); + assert.equal(result[11].url, pinned[11].url); + assert.isTrue(result[11].isPinned); + assert.equal(result[11].pinIndex, 11); + }); + it("should unpin previously pinned links no longer in the pinned list", () => { + const pinned = []; + links[2].isPinned = true; + links[2].pinIndex = 2; + const result = insertPinned(links, pinned); + assert.notProperty(result[2], "isPinned"); + assert.notProperty(result[2], "pinIndex"); + }); + it("should handle a link present in both the links and pinned list", () => { + const pinned = [links[7]]; + const result = insertPinned(links, pinned); + assert.equal(links.length, result.length); + }); + it("should not modify the original data", () => { + const pinned = [{ url: "http://example.com" }]; + + insertPinned(links, pinned); + + assert.equal(typeof pinned[0].isPinned, "undefined"); + }); + }); + describe("Snippets", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + Snippets(undefined, { type: "some_action" }), + INITIAL_STATE.Snippets + ); + }); + it("should set initialized to true on a SNIPPETS_DATA action", () => { + const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data: {} }); + assert.isTrue(state.initialized); + }); + it("should set the snippet data on a SNIPPETS_DATA action", () => { + const data = { snippetsURL: "foo.com", version: 4 }; + const state = Snippets(undefined, { type: at.SNIPPETS_DATA, data }); + assert.propertyVal(state, "snippetsURL", data.snippetsURL); + assert.propertyVal(state, "version", data.version); + }); + it("should reset to the initial state on a SNIPPETS_RESET action", () => { + const state = Snippets( + { initialized: true, foo: "bar" }, + { type: at.SNIPPETS_RESET } + ); + assert.equal(state, INITIAL_STATE.Snippets); + }); + it("should set the new blocklist on SNIPPET_BLOCKED", () => { + const state = Snippets( + { blockList: [] }, + { type: at.SNIPPET_BLOCKED, data: 1 } + ); + assert.deepEqual(state.blockList, [1]); + }); + it("should clear the blocklist on SNIPPETS_BLOCKLIST_CLEARED", () => { + const state = Snippets( + { blockList: [1, 2] }, + { type: at.SNIPPETS_BLOCKLIST_CLEARED } + ); + assert.deepEqual(state.blockList, []); + }); + }); + describe("Pocket", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + Pocket(undefined, { type: "some_action" }), + INITIAL_STATE.Pocket + ); + }); + it("should set waitingForSpoc on a POCKET_WAITING_FOR_SPOC action", () => { + const state = Pocket(undefined, { + type: at.POCKET_WAITING_FOR_SPOC, + data: false, + }); + assert.isFalse(state.waitingForSpoc); + }); + it("should have undefined for initial isUserLoggedIn state", () => { + assert.isNull(Pocket(undefined, { type: "some_action" }).isUserLoggedIn); + }); + it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with null", () => { + const state = Pocket(undefined, { + type: at.POCKET_LOGGED_IN, + data: null, + }); + assert.isFalse(state.isUserLoggedIn); + }); + it("should set isUserLoggedIn to false on a POCKET_LOGGED_IN with false", () => { + const state = Pocket(undefined, { + type: at.POCKET_LOGGED_IN, + data: false, + }); + assert.isFalse(state.isUserLoggedIn); + }); + it("should set isUserLoggedIn to true on a POCKET_LOGGED_IN with true", () => { + const state = Pocket(undefined, { + type: at.POCKET_LOGGED_IN, + data: true, + }); + assert.isTrue(state.isUserLoggedIn); + }); + it("should set pocketCta with correct object on a POCKET_CTA", () => { + const data = { + cta_button: "cta button", + cta_text: "cta text", + cta_url: "https://cta-url.com", + use_cta: true, + }; + const state = Pocket(undefined, { type: at.POCKET_CTA, data }); + assert.equal(state.pocketCta.ctaButton, data.cta_button); + assert.equal(state.pocketCta.ctaText, data.cta_text); + assert.equal(state.pocketCta.ctaUrl, data.cta_url); + assert.equal(state.pocketCta.useCta, data.use_cta); + }); + }); + describe("Personalization", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + Personalization(undefined, { type: "some_action" }), + INITIAL_STATE.Personalization + ); + }); + it("should set lastUpdated with DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED", () => { + const state = Personalization(undefined, { + type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED, + data: { + lastUpdated: 123, + }, + }); + assert.equal(state.lastUpdated, 123); + }); + it("should set initialized to true with DISCOVERY_STREAM_PERSONALIZATION_INIT", () => { + const state = Personalization(undefined, { + type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT, + }); + assert.equal(state.initialized, true); + }); + }); + describe("DiscoveryStream", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + DiscoveryStream(undefined, { type: "some_action" }), + INITIAL_STATE.DiscoveryStream + ); + }); + it("should set isPrivacyInfoModalVisible to true with SHOW_PRIVACY_INFO", () => { + const state = DiscoveryStream(undefined, { + type: at.SHOW_PRIVACY_INFO, + }); + assert.equal(state.isPrivacyInfoModalVisible, true); + }); + it("should set isPrivacyInfoModalVisible to false with HIDE_PRIVACY_INFO", () => { + const state = DiscoveryStream(undefined, { + type: at.HIDE_PRIVACY_INFO, + }); + assert.equal(state.isPrivacyInfoModalVisible, false); + }); + it("should set layout data with DISCOVERY_STREAM_LAYOUT_UPDATE", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: { layout: ["test"], lastUpdated: 123 }, + }); + assert.equal(state.layout[0], "test"); + assert.equal(state.lastUpdated, 123); + }); + it("should reset layout data with DISCOVERY_STREAM_LAYOUT_RESET", () => { + const layoutData = { layout: ["test"], lastUpdated: 123 }; + const feedsData = { + "https://foo.com/feed1": { lastUpdated: 123, data: [1, 2, 3] }, + }; + const spocsData = { + lastUpdated: 123, + spocs: [1, 2, 3], + }; + let state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, + data: layoutData, + }); + state = DiscoveryStream(state, { + type: at.DISCOVERY_STREAM_FEEDS_UPDATE, + data: feedsData, + }); + state = DiscoveryStream(state, { + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data: spocsData, + }); + state = DiscoveryStream(state, { + type: at.DISCOVERY_STREAM_LAYOUT_RESET, + }); + + assert.deepEqual(state, INITIAL_STATE.DiscoveryStream); + }); + it("should set config data with DISCOVERY_STREAM_CONFIG_CHANGE", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: { enabled: true }, + }); + assert.deepEqual(state.config, { enabled: true }); + }); + it("should set recentSavesEnabled with DISCOVERY_STREAM_PREFS_SETUP", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_PREFS_SETUP, + data: { recentSavesEnabled: true }, + }); + assert.isTrue(state.recentSavesEnabled); + }); + it("should set recentSavesData with DISCOVERY_STREAM_RECENT_SAVES", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { recentSaves: [1, 2, 3] }, + }); + assert.deepEqual(state.recentSavesData, [1, 2, 3]); + }); + it("should set isUserLoggedIn with DISCOVERY_STREAM_POCKET_STATE_SET", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { isUserLoggedIn: true }, + }); + assert.isTrue(state.isUserLoggedIn); + }); + it("should set feeds as loaded with DISCOVERY_STREAM_FEEDS_UPDATE", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_FEEDS_UPDATE, + }); + assert.isTrue(state.feeds.loaded); + }); + it("should set spoc_endpoint with DISCOVERY_STREAM_SPOCS_ENDPOINT", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT, + data: { url: "foo.com" }, + }); + assert.equal(state.spocs.spocs_endpoint, "foo.com"); + }); + it("should use initial state with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS, + data: {}, + }); + assert.deepEqual(state.spocs.placements, []); + }); + it("should set placements with DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => { + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS, + data: { + placements: [1, 2, 3], + }, + }); + assert.deepEqual(state.spocs.placements, [1, 2, 3]); + }); + it("should set spocs with DISCOVERY_STREAM_SPOCS_UPDATE", () => { + const data = { + lastUpdated: 123, + spocs: [1, 2, 3], + }; + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data, + }); + assert.deepEqual(state.spocs, { + spocs_endpoint: "", + data: [1, 2, 3], + lastUpdated: 123, + loaded: true, + frequency_caps: [], + blocked: [], + placements: [], + }); + }); + it("should default to a single spoc placement", () => { + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "https://foo.com" }, + }; + const oldState = { + spocs: { + data: { + spocs: { + items: [ + { + url: "test-spoc.com", + }, + ], + }, + }, + loaded: true, + }, + feeds: { + data: {}, + loaded: true, + }, + }; + + const newState = DiscoveryStream(oldState, deleteAction); + + assert.equal(newState.spocs.data.spocs.items.length, 1); + }); + it("should handle no data from DISCOVERY_STREAM_SPOCS_UPDATE", () => { + const data = null; + const state = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOCS_UPDATE, + data, + }); + assert.deepEqual(state.spocs, INITIAL_STATE.DiscoveryStream.spocs); + }); + it("should add blocked spocs to blocked array with DISCOVERY_STREAM_SPOC_BLOCKED", () => { + const firstState = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_SPOC_BLOCKED, + data: { url: "https://foo.com" }, + }); + const secondState = DiscoveryStream(firstState, { + type: at.DISCOVERY_STREAM_SPOC_BLOCKED, + data: { url: "https://bar.com" }, + }); + assert.deepEqual(firstState.spocs.blocked, ["https://foo.com"]); + assert.deepEqual(secondState.spocs.blocked, [ + "https://foo.com", + "https://bar.com", + ]); + }); + it("should not update state for empty action.data on DISCOVERY_STREAM_LINK_BLOCKED", () => { + const newState = DiscoveryStream(undefined, { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + }); + assert.equal(newState, INITIAL_STATE.DiscoveryStream); + }); + it("should not update state if feeds are not loaded", () => { + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "foo.com" }, + }; + const newState = DiscoveryStream(undefined, deleteAction); + assert.equal(newState, INITIAL_STATE.DiscoveryStream); + }); + it("should not update state if spocs and feeds data is undefined", () => { + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "foo.com" }, + }; + const oldState = { + spocs: { + data: {}, + loaded: true, + placements: [{ name: "spocs" }], + }, + feeds: { + data: {}, + loaded: true, + }, + }; + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual(newState, oldState); + }); + it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from spocs if feeds data is empty", () => { + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "https://foo.com" }, + }; + const oldState = { + spocs: { + data: { + spocs: { + items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + feeds: { + data: {}, + loaded: true, + }, + }; + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual(newState.spocs.data.spocs.items, [ + { url: "test-spoc.com" }, + ]); + }); + it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from feeds if spocs data is empty", () => { + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "https://foo.com" }, + }; + const oldState = { + spocs: { + data: {}, + loaded: true, + placements: [{ name: "spocs" }], + }, + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com" }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + }; + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + [{ url: "test.com" }] + ); + }); + it("should remove the site on DISCOVERY_STREAM_LINK_BLOCKED from both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com" }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + }; + const deleteAction = { + type: at.DISCOVERY_STREAM_LINK_BLOCKED, + data: { url: "https://foo.com" }, + }; + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual(newState.spocs.data.spocs.items, [ + { url: "test-spoc.com" }, + ]); + assert.deepEqual( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + [{ url: "test.com" }] + ); + }); + it("should not update state for empty action.data on PLACES_SAVED_TO_POCKET", () => { + const newState = DiscoveryStream(undefined, { + type: at.PLACES_SAVED_TO_POCKET, + }); + assert.equal(newState, INITIAL_STATE.DiscoveryStream); + }); + it("should add pocket_id on PLACES_SAVED_TO_POCKET in both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com" }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }], + }, + }, + placements: [{ name: "spocs" }], + loaded: true, + }, + }; + const action = { + type: at.PLACES_SAVED_TO_POCKET, + data: { + url: "https://foo.com", + pocket_id: 1234, + open_url: "https://foo-1234", + }, + }; + + const newState = DiscoveryStream(oldState, action); + + assert.lengthOf(newState.spocs.data.spocs.items, 2); + assert.equal( + newState.spocs.data.spocs.items[0].pocket_id, + action.data.pocket_id + ); + assert.equal( + newState.spocs.data.spocs.items[0].open_url, + action.data.open_url + ); + assert.isUndefined(newState.spocs.data.spocs.items[1].pocket_id); + + assert.lengthOf( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + 2 + ); + assert.equal( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .pocket_id, + action.data.pocket_id + ); + assert.equal( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .open_url, + action.data.open_url + ); + assert.isUndefined( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[1] + .pocket_id + ); + }); + it("should not update state for empty action.data on DELETE_FROM_POCKET", () => { + const newState = DiscoveryStream(undefined, { + type: at.DELETE_FROM_POCKET, + }); + assert.equal(newState, INITIAL_STATE.DiscoveryStream); + }); + it("should remove site on DELETE_FROM_POCKET in both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com", pocket_id: 1234 }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [ + { url: "https://foo.com", pocket_id: 1234 }, + { url: "test-spoc.com" }, + ], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + }; + const deleteAction = { + type: at.DELETE_FROM_POCKET, + data: { + pocket_id: 1234, + }, + }; + + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual(newState.spocs.data.spocs.items, [ + { url: "test-spoc.com" }, + ]); + assert.deepEqual( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + [{ url: "test.com" }] + ); + }); + it("should remove site on ARCHIVE_FROM_POCKET in both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com", pocket_id: 1234 }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [ + { url: "https://foo.com", pocket_id: 1234 }, + { url: "test-spoc.com" }, + ], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + }; + const deleteAction = { + type: at.ARCHIVE_FROM_POCKET, + data: { + pocket_id: 1234, + }, + }; + + const newState = DiscoveryStream(oldState, deleteAction); + assert.deepEqual(newState.spocs.data.spocs.items, [ + { url: "test-spoc.com" }, + ]); + assert.deepEqual( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + [{ url: "test.com" }] + ); + }); + it("should add boookmark details on PLACES_BOOKMARK_ADDED in both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { url: "https://foo.com" }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [{ url: "https://foo.com" }, { url: "test-spoc.com" }], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + }; + const bookmarkAction = { + type: at.PLACES_BOOKMARK_ADDED, + data: { + url: "https://foo.com", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + dateAdded: 1234567, + }, + }; + + const newState = DiscoveryStream(oldState, bookmarkAction); + + assert.lengthOf(newState.spocs.data.spocs.items, 2); + assert.equal( + newState.spocs.data.spocs.items[0].bookmarkGuid, + bookmarkAction.data.bookmarkGuid + ); + assert.equal( + newState.spocs.data.spocs.items[0].bookmarkTitle, + bookmarkAction.data.bookmarkTitle + ); + assert.isUndefined(newState.spocs.data.spocs.items[1].bookmarkGuid); + + assert.lengthOf( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + 2 + ); + assert.equal( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .bookmarkGuid, + bookmarkAction.data.bookmarkGuid + ); + assert.equal( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .bookmarkTitle, + bookmarkAction.data.bookmarkTitle + ); + assert.isUndefined( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[1] + .bookmarkGuid + ); + }); + + it("should remove boookmark details on PLACES_BOOKMARKS_REMOVED in both feeds and spocs", () => { + const oldState = { + feeds: { + data: { + "https://foo.com/feed1": { + data: { + recommendations: [ + { + url: "https://foo.com", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + }, + { url: "test.com" }, + ], + }, + }, + }, + loaded: true, + }, + spocs: { + data: { + spocs: { + items: [ + { + url: "https://foo.com", + bookmarkGuid: "bookmark123", + bookmarkTitle: "Title for bar.com", + }, + { url: "test-spoc.com" }, + ], + }, + }, + loaded: true, + placements: [{ name: "spocs" }], + }, + }; + const action = { + type: at.PLACES_BOOKMARKS_REMOVED, + data: { + urls: ["https://foo.com"], + }, + }; + + const newState = DiscoveryStream(oldState, action); + + assert.lengthOf(newState.spocs.data.spocs.items, 2); + assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkGuid); + assert.isUndefined(newState.spocs.data.spocs.items[0].bookmarkTitle); + + assert.lengthOf( + newState.feeds.data["https://foo.com/feed1"].data.recommendations, + 2 + ); + assert.isUndefined( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .bookmarkGuid + ); + assert.isUndefined( + newState.feeds.data["https://foo.com/feed1"].data.recommendations[0] + .bookmarkTitle + ); + }); + describe("PREF_CHANGED", () => { + it("should set isCollectionDismissible", () => { + const state = DiscoveryStream(undefined, { + type: at.PREF_CHANGED, + data: { + name: "discoverystream.isCollectionDismissible", + value: true, + }, + }); + assert.equal(state.isCollectionDismissible, true); + }); + }); + }); + describe("Search", () => { + it("should return INITIAL_STATE by default", () => { + assert.equal( + Search(undefined, { type: "some_action" }), + INITIAL_STATE.Search + ); + }); + it("should set disable to true on DISABLE_SEARCH", () => { + const nextState = Search(undefined, { type: "DISABLE_SEARCH" }); + assert.propertyVal(nextState, "disable", true); + }); + it("should set focus to true on FAKE_FOCUS_SEARCH", () => { + const nextState = Search(undefined, { type: "FAKE_FOCUS_SEARCH" }); + assert.propertyVal(nextState, "fakeFocus", true); + }); + it("should set focus and disable to false on SHOW_SEARCH", () => { + const nextState = Search(undefined, { type: "SHOW_SEARCH" }); + assert.propertyVal(nextState, "fakeFocus", false); + assert.propertyVal(nextState, "disable", false); + }); + }); + it("should set initialized to true on AS_ROUTER_INITIALIZED", () => { + const nextState = ASRouter(undefined, { type: "AS_ROUTER_INITIALIZED" }); + assert.propertyVal(nextState, "initialized", true); + }); +}); 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, + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/AboutPreferences.test.js b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js new file mode 100644 index 0000000000..f355c6f0ab --- /dev/null +++ b/browser/components/newtab/test/unit/lib/AboutPreferences.test.js @@ -0,0 +1,429 @@ +/* global Services */ +import { + AboutPreferences, + PREFERENCES_LOADED_EVENT, +} from "lib/AboutPreferences.jsm"; +import { + actionTypes as at, + actionCreators as ac, +} from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("AboutPreferences Feed", () => { + let globals; + let sandbox; + let Sections; + let DiscoveryStream; + let instance; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + Sections = []; + DiscoveryStream = { config: { enabled: false } }; + instance = new AboutPreferences(); + instance.store = { + dispatch: sandbox.stub(), + getState: () => ({ Sections, DiscoveryStream }), + }; + globals.set("NimbusFeatures", { + newtab: { getAllVariables: sandbox.stub() }, + }); + }); + afterEach(() => { + globals.restore(); + }); + + describe("#onAction", () => { + it("should call .init() on an INIT action", () => { + const stub = sandbox.stub(instance, "init"); + + instance.onAction({ type: at.INIT }); + + assert.calledOnce(stub); + }); + it("should call .uninit() on an UNINIT action", () => { + const stub = sandbox.stub(instance, "uninit"); + + instance.onAction({ type: at.UNINIT }); + + assert.calledOnce(stub); + }); + it("should call .openPreferences on SETTINGS_OPEN", () => { + const action = { + type: at.SETTINGS_OPEN, + _target: { browser: { ownerGlobal: { openPreferences: sinon.spy() } } }, + }; + instance.onAction(action); + assert.calledOnce(action._target.browser.ownerGlobal.openPreferences); + }); + it("should call .BrowserOpenAddonsMgr with the extension id on OPEN_WEBEXT_SETTINGS", () => { + const action = { + type: at.OPEN_WEBEXT_SETTINGS, + data: "foo", + _target: { + browser: { ownerGlobal: { BrowserOpenAddonsMgr: sinon.spy() } }, + }, + }; + instance.onAction(action); + assert.calledWith( + action._target.browser.ownerGlobal.BrowserOpenAddonsMgr, + "addons://detail/foo" + ); + }); + }); + describe("#observe", () => { + it("should watch for about:preferences loading", () => { + sandbox.stub(Services.obs, "addObserver"); + + instance.init(); + + assert.calledOnce(Services.obs.addObserver); + assert.calledWith( + Services.obs.addObserver, + instance, + PREFERENCES_LOADED_EVENT + ); + }); + it("should stop watching on uninit", () => { + sandbox.stub(Services.obs, "removeObserver"); + + instance.uninit(); + + assert.calledOnce(Services.obs.removeObserver); + assert.calledWith( + Services.obs.removeObserver, + instance, + PREFERENCES_LOADED_EVENT + ); + }); + it("should try to render on event", async () => { + const stub = sandbox.stub(instance, "renderPreferences"); + Sections.push({}); + + await instance.observe(window, PREFERENCES_LOADED_EVENT); + + assert.calledOnce(stub); + assert.equal(stub.firstCall.args[0], window); + assert.include(stub.firstCall.args[1], Sections[0]); + }); + it("Hide topstories rows select in sections if discovery stream is enabled", async () => { + const stub = sandbox.stub(instance, "renderPreferences"); + + Sections.push({ + rowsPref: "row_pref", + maxRows: 3, + pref: { descString: "foo" }, + learnMore: { link: "https://foo.com" }, + id: "topstories", + }); + DiscoveryStream = { config: { enabled: true } }; + + await instance.observe(window, PREFERENCES_LOADED_EVENT); + + assert.calledOnce(stub); + const [, structure] = stub.firstCall.args; + assert.equal(structure[0].id, "search"); + assert.equal(structure[1].id, "topsites"); + assert.equal(structure[2].id, "topstories"); + assert.isEmpty(structure[2].rowsPref); + }); + }); + describe("#renderPreferences", () => { + let node; + let prefStructure; + let Preferences; + let gHomePane; + const testRender = () => + instance.renderPreferences( + { + document: { + createXULElement: sandbox.stub().returns(node), + l10n: { + setAttributes(el, id, args) { + el.setAttribute("data-l10n-id", id); + el.setAttribute("data-l10n-args", JSON.stringify(args)); + }, + }, + createProcessingInstruction: sandbox.stub(), + createElementNS: sandbox.stub().callsFake((NS, el) => node), + getElementById: sandbox.stub().returns(node), + insertBefore: sandbox.stub().returnsArg(0), + querySelector: sandbox + .stub() + .returns({ appendChild: sandbox.stub() }), + }, + Preferences, + gHomePane, + }, + prefStructure, + DiscoveryStream.config + ); + beforeEach(() => { + node = { + appendChild: sandbox.stub().returnsArg(0), + addEventListener: sandbox.stub(), + classList: { add: sandbox.stub(), remove: sandbox.stub() }, + cloneNode: sandbox.stub().returnsThis(), + insertAdjacentElement: sandbox.stub().returnsArg(1), + setAttribute: sandbox.stub(), + remove: sandbox.stub(), + style: {}, + }; + prefStructure = []; + Preferences = { + add: sandbox.stub(), + get: sandbox.stub().returns({ + on: sandbox.stub(), + }), + }; + gHomePane = { toggleRestoreDefaultsBtn: sandbox.stub() }; + }); + describe("#getString", () => { + it("should not fail if titleString is not provided", () => { + prefStructure = [{ pref: {} }]; + + testRender(); + assert.calledWith( + node.setAttribute, + "data-l10n-id", + sinon.match.typeOf("undefined") + ); + }); + it("should return the string id if titleString is just a string", () => { + const titleString = "foo"; + prefStructure = [{ pref: { titleString } }]; + + testRender(); + assert.calledWith(node.setAttribute, "data-l10n-id", titleString); + }); + it("should set id and args if titleString is an object with id and values", () => { + const titleString = { id: "foo", values: { provider: "bar" } }; + prefStructure = [{ pref: { titleString } }]; + + testRender(); + assert.calledWith(node.setAttribute, "data-l10n-id", titleString.id); + assert.calledWith( + node.setAttribute, + "data-l10n-args", + JSON.stringify(titleString.values) + ); + }); + }); + describe("#linkPref", () => { + it("should add a pref to the global", () => { + prefStructure = [{ pref: { feed: "feed" } }]; + + testRender(); + + assert.calledOnce(Preferences.add); + }); + it("should skip adding if not shown", () => { + prefStructure = [{ shouldHidePref: true }]; + + testRender(); + + assert.notCalled(Preferences.add); + }); + }); + describe("pref icon", () => { + it("should default to webextension icon", () => { + prefStructure = [{ pref: { feed: "feed" } }]; + + testRender(); + + assert.calledWith( + node.setAttribute, + "src", + "chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg" + ); + }); + it("should use desired glyph icon", () => { + prefStructure = [{ icon: "mail", pref: { feed: "feed" } }]; + + testRender(); + + assert.calledWith( + node.setAttribute, + "src", + "chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg" + ); + }); + it("should use specified chrome icon", () => { + const icon = "chrome://the/icon.svg"; + prefStructure = [{ icon, pref: { feed: "feed" } }]; + + testRender(); + + assert.calledWith(node.setAttribute, "src", icon); + }); + }); + describe("title line", () => { + it("should render a title", () => { + const titleString = "the_title"; + prefStructure = [{ pref: { titleString } }]; + + testRender(); + + assert.calledWith(node.setAttribute, "data-l10n-id", titleString); + }); + }); + describe("top stories", () => { + const href = "https://disclaimer/"; + const eventSource = "https://disclaimer/"; + beforeEach(() => { + prefStructure = [ + { + id: "topstories", + pref: { feed: "feed", learnMore: { link: { href } } }, + eventSource, + }, + ]; + }); + it("should add a link for top stories", () => { + testRender(); + assert.calledWith(node.setAttribute, "href", href); + }); + it("should setup a user event for top stories eventSource", () => { + sinon.spy(instance, "setupUserEvent"); + testRender(); + assert.calledWith(node.addEventListener, "command"); + assert.calledWith(instance.setupUserEvent, node, eventSource); + }); + it("should setup a user event for top stories nested pref eventSource", () => { + sinon.spy(instance, "setupUserEvent"); + prefStructure = [ + { + id: "topstories", + pref: { + feed: "feed", + learnMore: { link: { href } }, + nestedPrefs: [ + { + name: "showSponsored", + titleString: + "home-prefs-recommended-by-option-sponsored-stories", + icon: "icon-info", + eventSource: "POCKET_SPOCS", + }, + ], + }, + }, + ]; + testRender(); + assert.calledWith(node.addEventListener, "command"); + assert.calledWith(instance.setupUserEvent, node, "POCKET_SPOCS"); + }); + it("should fire store dispatch with onCommand", () => { + const element = { + addEventListener: (command, action) => { + // Trigger the action right away because we only care about testing the action here. + action({ target: { checked: true } }); + }, + }; + instance.setupUserEvent(element, eventSource); + assert.calledWith( + instance.store.dispatch, + ac.UserEvent({ + event: "PREF_CHANGED", + source: eventSource, + value: { menu_source: "ABOUT_PREFERENCES", status: true }, + }) + ); + }); + }); + describe("description line", () => { + it("should render a description", () => { + const descString = "the_desc"; + prefStructure = [{ pref: { descString } }]; + + testRender(); + + assert.calledWith(node.setAttribute, "data-l10n-id", descString); + }); + it("should render rows dropdown with appropriate number", () => { + prefStructure = [ + { rowsPref: "row_pref", maxRows: 3, pref: { descString: "foo" } }, + ]; + + testRender(); + + assert.calledWith(node.setAttribute, "value", 1); + assert.calledWith(node.setAttribute, "value", 2); + assert.calledWith(node.setAttribute, "value", 3); + }); + }); + describe("nested prefs", () => { + const titleString = "im_nested"; + beforeEach(() => { + prefStructure = [{ pref: { nestedPrefs: [{ titleString }] } }]; + }); + it("should render a nested pref", () => { + testRender(); + + assert.calledWith(node.setAttribute, "data-l10n-id", titleString); + }); + it("should set node hidden to true", () => { + prefStructure[0].pref.nestedPrefs[0].hidden = true; + + testRender(); + + assert.isTrue(node.hidden); + }); + it("should add a change event", () => { + testRender(); + + assert.calledOnce(Preferences.get().on); + assert.calledWith(Preferences.get().on, "change"); + }); + it("should default node disabled to false", async () => { + Preferences.get = sandbox.stub().returns({ + on: sandbox.stub(), + _value: true, + }); + + testRender(); + + assert.isFalse(node.disabled); + }); + it("should default node disabled to true", async () => { + testRender(); + + assert.isTrue(node.disabled); + }); + it("should set node disabled to true", async () => { + const pref = { + on: sandbox.stub(), + _value: true, + }; + Preferences.get = sandbox.stub().returns(pref); + + testRender(); + pref._value = !pref._value; + await Preferences.get().on.firstCall.args[1](); + + assert.isTrue(node.disabled); + }); + it("should set node disabled to false", async () => { + const pref = { + on: sandbox.stub(), + _value: false, + }; + Preferences.get = sandbox.stub().returns(pref); + + testRender(); + pref._value = !pref._value; + await Preferences.get().on.firstCall.args[1](); + + assert.isFalse(node.disabled); + }); + }); + describe("restore defaults btn", () => { + it("should call toggleRestoreDefaultsBtn", () => { + testRender(); + + assert.calledOnce(gHomePane.toggleRestoreDefaultsBtn); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ActivityStream.test.js b/browser/components/newtab/test/unit/lib/ActivityStream.test.js new file mode 100644 index 0000000000..47880d00bc --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStream.test.js @@ -0,0 +1,576 @@ +import { CONTENT_MESSAGE_TYPE } from "common/Actions.sys.mjs"; +import { ActivityStream, PREFS_CONFIG } from "lib/ActivityStream.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +import { DEFAULT_SITES } from "lib/DefaultSites.sys.mjs"; +import { AboutPreferences } from "lib/AboutPreferences.jsm"; +import { DefaultPrefs } from "lib/ActivityStreamPrefs.jsm"; +import { NewTabInit } from "lib/NewTabInit.jsm"; +import { SectionsFeed } from "lib/SectionsManager.jsm"; +import { RecommendationProvider } from "lib/RecommendationProvider.jsm"; +import { PlacesFeed } from "lib/PlacesFeed.jsm"; +import { PrefsFeed } from "lib/PrefsFeed.jsm"; +import { SystemTickFeed } from "lib/SystemTickFeed.jsm"; +import { TelemetryFeed } from "lib/TelemetryFeed.jsm"; +import { FaviconFeed } from "lib/FaviconFeed.jsm"; +import { TopSitesFeed } from "lib/TopSitesFeed.jsm"; +import { TopStoriesFeed } from "lib/TopStoriesFeed.jsm"; +import { HighlightsFeed } from "lib/HighlightsFeed.jsm"; +import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm"; + +import { LinksCache } from "lib/LinksCache.sys.mjs"; +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; +import { DownloadsManager } from "lib/DownloadsManager.jsm"; + +describe("ActivityStream", () => { + let sandbox; + let as; + function FakeStore() { + return { init: () => {}, uninit: () => {}, feeds: { get: () => {} } }; + } + + let globals; + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set({ + Store: FakeStore, + + DEFAULT_SITES, + AboutPreferences, + DefaultPrefs, + NewTabInit, + SectionsFeed, + RecommendationProvider, + PlacesFeed, + PrefsFeed, + SystemTickFeed, + TelemetryFeed, + FaviconFeed, + TopSitesFeed, + TopStoriesFeed, + HighlightsFeed, + DiscoveryStreamFeed, + + LinksCache, + PersistentCache, + DownloadsManager, + }); + + as = new ActivityStream(); + sandbox = sinon.createSandbox(); + sandbox.stub(as.store, "init"); + sandbox.stub(as.store, "uninit"); + sandbox.stub(as._defaultPrefs, "init"); + PREFS_CONFIG.get("feeds.system.topstories").value = undefined; + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should exist", () => { + assert.ok(ActivityStream); + }); + it("should initialize with .initialized=false", () => { + assert.isFalse(as.initialized, ".initialized"); + }); + describe("#init", () => { + beforeEach(() => { + as.init(); + }); + it("should initialize default prefs", () => { + assert.calledOnce(as._defaultPrefs.init); + }); + it("should set .initialized to true", () => { + assert.isTrue(as.initialized, ".initialized"); + }); + it("should call .store.init", () => { + assert.calledOnce(as.store.init); + }); + it("should pass to Store an INIT event for content", () => { + as.init(); + + const [, action] = as.store.init.firstCall.args; + assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE); + }); + it("should pass to Store an UNINIT event", () => { + as.init(); + + const [, , action] = as.store.init.firstCall.args; + assert.equal(action.type, "UNINIT"); + }); + it("should clear old default discoverystream config pref", () => { + sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true); + sandbox + .stub(global.Services.prefs, "getStringPref") + .returns( + `{"api_key_pref":"extensions.pocket.oAuthConsumerKey","enabled":false,"show_spocs":true,"layout_endpoint":"https://getpocket.cdn.mozilla.net/v3/newtab/layout?version=1&consumer_key=$apiKey&layout_variant=basic"}` + ); + sandbox.stub(global.Services.prefs, "clearUserPref"); + + as.init(); + + assert.calledWith( + global.Services.prefs.clearUserPref, + "browser.newtabpage.activity-stream.discoverystream.config" + ); + }); + it("should call addObserver for the app locales", () => { + sandbox.stub(global.Services.obs, "addObserver"); + as.init(); + assert.calledWith( + global.Services.obs.addObserver, + as, + "intl:app-locales-changed" + ); + }); + }); + describe("#uninit", () => { + beforeEach(() => { + as.init(); + as.uninit(); + }); + it("should set .initialized to false", () => { + assert.isFalse(as.initialized, ".initialized"); + }); + it("should call .store.uninit", () => { + assert.calledOnce(as.store.uninit); + }); + it("should call removeObserver for the region", () => { + sandbox.stub(global.Services.obs, "removeObserver"); + as.geo = ""; + as.uninit(); + assert.calledWith( + global.Services.obs.removeObserver, + as, + global.Region.REGION_TOPIC + ); + }); + it("should call removeObserver for the app locales", () => { + sandbox.stub(global.Services.obs, "removeObserver"); + as.uninit(); + assert.calledWith( + global.Services.obs.removeObserver, + as, + "intl:app-locales-changed" + ); + }); + }); + describe("#observe", () => { + it("should call _updateDynamicPrefs from observe", () => { + sandbox.stub(as, "_updateDynamicPrefs"); + as.observe(undefined, global.Region.REGION_TOPIC); + assert.calledOnce(as._updateDynamicPrefs); + }); + }); + describe("feeds", () => { + it("should create a NewTabInit feed", () => { + const feed = as.feeds.get("feeds.newtabinit")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a Places feed", () => { + const feed = as.feeds.get("feeds.places")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a TopSites feed", () => { + const feed = as.feeds.get("feeds.system.topsites")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a Telemetry feed", () => { + const feed = as.feeds.get("feeds.telemetry")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a Prefs feed", () => { + const feed = as.feeds.get("feeds.prefs")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a HighlightsFeed feed", () => { + const feed = as.feeds.get("feeds.section.highlights")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a TopStoriesFeed feed", () => { + const feed = as.feeds.get("feeds.system.topstories")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a AboutPreferences feed", () => { + const feed = as.feeds.get("feeds.aboutpreferences")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a SectionsFeed", () => { + const feed = as.feeds.get("feeds.sections")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a SystemTick feed", () => { + const feed = as.feeds.get("feeds.systemtick")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a Favicon feed", () => { + const feed = as.feeds.get("feeds.favicon")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a RecommendationProvider feed", () => { + const feed = as.feeds.get("feeds.recommendationprovider")(); + assert.ok(feed, "feed should exist"); + }); + it("should create a DiscoveryStreamFeed feed", () => { + const feed = as.feeds.get("feeds.discoverystreamfeed")(); + assert.ok(feed, "feed should exist"); + }); + }); + describe("_migratePref", () => { + it("should migrate a pref if the user has set a custom value", () => { + sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true); + sandbox.stub(global.Services.prefs, "getPrefType").returns("integer"); + sandbox.stub(global.Services.prefs, "getIntPref").returns(10); + as._migratePref("oldPrefName", result => assert.equal(10, result)); + }); + it("should not migrate a pref if the user has not set a custom value", () => { + // we bailed out early so we don't check the pref type later + sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(false); + sandbox.stub(global.Services.prefs, "getPrefType"); + as._migratePref("oldPrefName"); + assert.notCalled(global.Services.prefs.getPrefType); + }); + it("should use the proper pref getter for each type", () => { + sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true); + + // Integer + sandbox.stub(global.Services.prefs, "getIntPref"); + sandbox.stub(global.Services.prefs, "getPrefType").returns("integer"); + as._migratePref("oldPrefName", () => {}); + assert.calledWith(global.Services.prefs.getIntPref, "oldPrefName"); + + // Boolean + sandbox.stub(global.Services.prefs, "getBoolPref"); + global.Services.prefs.getPrefType.returns("boolean"); + as._migratePref("oldPrefName", () => {}); + assert.calledWith(global.Services.prefs.getBoolPref, "oldPrefName"); + + // String + sandbox.stub(global.Services.prefs, "getStringPref"); + global.Services.prefs.getPrefType.returns("string"); + as._migratePref("oldPrefName", () => {}); + assert.calledWith(global.Services.prefs.getStringPref, "oldPrefName"); + }); + it("should clear the old pref after setting the new one", () => { + sandbox.stub(global.Services.prefs, "prefHasUserValue").returns(true); + sandbox.stub(global.Services.prefs, "clearUserPref"); + sandbox.stub(global.Services.prefs, "getPrefType").returns("integer"); + as._migratePref("oldPrefName", () => {}); + assert.calledWith(global.Services.prefs.clearUserPref, "oldPrefName"); + }); + }); + describe("discoverystream.region-basic-layout config", () => { + let getStringPrefStub; + beforeEach(() => { + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + sandbox.stub(global.Region, "home").get(() => "CA"); + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-CA"); + }); + it("should enable 7 row layout pref if no basic config is set and no geo is set", () => { + getStringPrefStub + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-basic-config" + ) + .returns(""); + sandbox.stub(global.Region, "home").get(() => ""); + + as._updateDynamicPrefs(); + + assert.isFalse( + PREFS_CONFIG.get("discoverystream.region-basic-layout").value + ); + }); + it("should enable 1 row layout pref based on region layout pref", () => { + getStringPrefStub + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-basic-config" + ) + .returns("CA"); + + as._updateDynamicPrefs(); + + assert.isTrue( + PREFS_CONFIG.get("discoverystream.region-basic-layout").value + ); + }); + it("should enable 7 row layout pref based on region layout pref", () => { + getStringPrefStub + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-basic-config" + ) + .returns(""); + + as._updateDynamicPrefs(); + + assert.isFalse( + PREFS_CONFIG.get("discoverystream.region-basic-layout").value + ); + }); + }); + describe("_updateDynamicPrefs topstories default value", () => { + let getVariableStub; + let getBoolPrefStub; + let appLocaleAsBCP47Stub; + beforeEach(() => { + getVariableStub = sandbox.stub( + global.NimbusFeatures.pocketNewtab, + "getVariable" + ); + appLocaleAsBCP47Stub = sandbox.stub( + global.Services.locale, + "appLocaleAsBCP47" + ); + + getBoolPrefStub = sandbox.stub(global.Services.prefs, "getBoolPref"); + getBoolPrefStub + .withArgs("browser.newtabpage.activity-stream.feeds.section.topstories") + .returns(true); + + appLocaleAsBCP47Stub.get(() => "en-US"); + + sandbox.stub(global.Region, "home").get(() => "US"); + + getVariableStub.withArgs("regionStoriesConfig").returns("US,CA"); + }); + it("should be false with no geo/locale", () => { + appLocaleAsBCP47Stub.get(() => ""); + sandbox.stub(global.Region, "home").get(() => ""); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be false with no geo but an allowed locale", () => { + appLocaleAsBCP47Stub.get(() => ""); + sandbox.stub(global.Region, "home").get(() => ""); + appLocaleAsBCP47Stub.get(() => "en-US"); + getVariableStub + .withArgs("localeListConfig") + .returns("en-US,en-CA,en-GB") + // We only have this pref set to trigger a close to real situation. + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-stories-block" + ) + .returns("FR"); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be false with unexpected geo", () => { + sandbox.stub(global.Region, "home").get(() => "NOGEO"); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be false with expected geo and unexpected locale", () => { + appLocaleAsBCP47Stub.get(() => "no-LOCALE"); + + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be true with expected geo and locale", () => { + as._updateDynamicPrefs(); + assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be false after expected geo and locale then unexpected", () => { + sandbox + .stub(global.Region, "home") + .onFirstCall() + .get(() => "US") + .onSecondCall() + .get(() => "NOGEO"); + + as._updateDynamicPrefs(); + as._updateDynamicPrefs(); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be true with updated pref change", () => { + appLocaleAsBCP47Stub.get(() => "en-GB"); + sandbox.stub(global.Region, "home").get(() => "GB"); + getVariableStub.withArgs("regionStoriesConfig").returns("GB"); + + as._updateDynamicPrefs(); + + assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should be true with allowed locale in non US region", () => { + appLocaleAsBCP47Stub.get(() => "en-CA"); + sandbox.stub(global.Region, "home").get(() => "DE"); + getVariableStub.withArgs("localeListConfig").returns("en-US,en-CA,en-GB"); + + as._updateDynamicPrefs(); + + assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + }); + describe("_updateDynamicPrefs topstories delayed default value", () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(); + + // Have addObserver cause prefHasUserValue to now return true then observe + sandbox + .stub(global.Services.obs, "addObserver") + .callsFake((pref, obs) => { + setTimeout(() => { + Services.obs.notifyObservers("US", "browser-region-updated"); + }); + }); + }); + afterEach(() => clock.restore()); + + it("should set false with unexpected geo", () => { + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs("browser.search.region") + .returns("NOGEO"); + + as._updateDynamicPrefs(); + + clock.tick(1); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should set true with expected geo and locale", () => { + sandbox + .stub(global.NimbusFeatures.pocketNewtab, "getVariable") + .withArgs("regionStoriesConfig") + .returns("US"); + + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + as._updateDynamicPrefs(); + clock.tick(1); + + assert.isTrue(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should not change default even with expected geo and locale", () => { + as._defaultPrefs.set("feeds.system.topstories", false); + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-stories-config" + ) + .returns("US"); + + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + as._updateDynamicPrefs(); + clock.tick(1); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + it("should set false with geo blocked", () => { + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-stories-config" + ) + .returns("US") + .withArgs( + "browser.newtabpage.activity-stream.discoverystream.region-stories-block" + ) + .returns("US"); + + sandbox.stub(global.Services.prefs, "getBoolPref").returns(true); + sandbox + .stub(global.Services.locale, "appLocaleAsBCP47") + .get(() => "en-US"); + + as._updateDynamicPrefs(); + clock.tick(1); + + assert.isFalse(PREFS_CONFIG.get("feeds.system.topstories").value); + }); + }); + describe("telemetry reporting on init failure", () => { + it("should send a ping on init error", () => { + as = new ActivityStream(); + const telemetry = { handleUndesiredEvent: sandbox.spy() }; + sandbox.stub(as.store, "init").throws(); + sandbox.stub(as.store.feeds, "get").returns(telemetry); + try { + as.init(); + } catch (e) {} + assert.calledOnce(telemetry.handleUndesiredEvent); + }); + }); + + describe("searchs shortcuts shouldPin pref", () => { + const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF = + "improvesearch.topSiteSearchShortcuts.searchEngines"; + let stub; + + beforeEach(() => { + stub = sandbox.stub(global.Region, "home"); + }); + + it("should be an empty string when no geo is available", () => { + stub.get(() => ""); + as._updateDynamicPrefs(); + assert.equal( + PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value, + "" + ); + }); + + it("should be 'baidu' in China", () => { + stub.get(() => "CN"); + as._updateDynamicPrefs(); + assert.equal( + PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value, + "baidu" + ); + }); + + it("should be 'yandex' in Russia, Belarus, Kazakhstan, and Turkey", () => { + const geos = ["BY", "KZ", "RU", "TR"]; + for (const geo of geos) { + stub.get(() => geo); + as._updateDynamicPrefs(); + assert.equal( + PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value, + "yandex" + ); + } + }); + + it("should be 'google,amazon' in Germany, France, the UK, Japan, Italy, and the US", () => { + const geos = ["DE", "FR", "GB", "IT", "JP", "US"]; + for (const geo of geos) { + stub.returns(geo); + as._updateDynamicPrefs(); + assert.equal( + PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value, + "google,amazon" + ); + } + }); + + it("should be 'google' elsewhere", () => { + // A selection of other geos + const geos = ["BR", "CA", "ES", "ID", "IN"]; + for (const geo of geos) { + stub.get(() => geo); + as._updateDynamicPrefs(); + assert.equal( + PREFS_CONFIG.get(SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF).value, + "google" + ); + } + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js new file mode 100644 index 0000000000..b6aeacead2 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamMessageChannel.test.js @@ -0,0 +1,432 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { + ActivityStreamMessageChannel, + DEFAULT_OPTIONS, +} from "lib/ActivityStreamMessageChannel.jsm"; +import { addNumberReducer, GlobalOverrider } from "test/unit/utils"; +import { applyMiddleware, createStore } from "redux"; + +const OPTIONS = [ + "pageURL, outgoingMessageName", + "incomingMessageName", + "dispatch", +]; + +// Create an object containing details about a tab as expected within +// the loaded tabs map in ActivityStreamMessageChannel.jsm. +function getTabDetails(portID, url = "about:newtab", extraArgs = {}) { + let actor = { + portID, + sendAsyncMessage: sinon.spy(), + }; + let browser = { + getAttribute: () => (extraArgs.preloaded ? "preloaded" : ""), + ownerGlobal: {}, + }; + let browsingContext = { + top: { + embedderElement: browser, + }, + }; + + let data = { + data: { + actor, + browser, + browsingContext, + portID, + url, + }, + target: { + browsingContext, + }, + }; + + if (extraArgs.loaded) { + data.data.loaded = extraArgs.loaded; + } + if (extraArgs.simulated) { + data.data.simulated = extraArgs.simulated; + } + + return data; +} + +describe("ActivityStreamMessageChannel", () => { + let globals; + let dispatch; + let mm; + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set("AboutNewTab", { + reset: globals.sandbox.spy(), + }); + globals.set("AboutHomeStartupCache", { onPreloadedNewTabMessage() {} }); + dispatch = globals.sandbox.spy(); + mm = new ActivityStreamMessageChannel({ dispatch }); + + assert.ok(mm.loadedTabs, []); + + let loadedTabs = new Map(); + let sandbox = sinon.createSandbox(); + sandbox.stub(mm, "loadedTabs").get(() => loadedTabs); + }); + + afterEach(() => globals.restore()); + + describe("portID validation", () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.spy(global.console, "error"); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should log errors for an invalid portID", () => { + mm.validatePortID({}); + mm.validatePortID({}); + mm.validatePortID({}); + + assert.equal(global.console.error.callCount, 3); + }); + }); + + it("should exist", () => { + assert.ok(ActivityStreamMessageChannel); + }); + it("should apply default options", () => { + mm = new ActivityStreamMessageChannel(); + OPTIONS.forEach(o => assert.equal(mm[o], DEFAULT_OPTIONS[o], o)); + }); + it("should add options", () => { + const options = { + dispatch: () => {}, + pageURL: "FOO.html", + outgoingMessageName: "OUT", + incomingMessageName: "IN", + }; + mm = new ActivityStreamMessageChannel(options); + OPTIONS.forEach(o => assert.equal(mm[o], options[o], o)); + }); + it("should throw an error if no dispatcher was provided", () => { + mm = new ActivityStreamMessageChannel(); + assert.throws(() => mm.dispatch({ type: "FOO" })); + }); + describe("Creating/destroying the channel", () => { + describe("#simulateMessagesForExistingTabs", () => { + beforeEach(() => { + sinon.stub(mm, "onActionFromContent"); + }); + it("should simulate init for existing ports", () => { + let msg1 = getTabDetails("inited", "about:monkeys", { + simulated: true, + }); + mm.loadedTabs.set(msg1.data.browser, msg1.data); + + let msg2 = getTabDetails("loaded", "about:sheep", { + simulated: true, + }); + mm.loadedTabs.set(msg2.data.browser, msg2.data); + + mm.simulateMessagesForExistingTabs(); + + assert.calledWith(mm.onActionFromContent.firstCall, { + type: at.NEW_TAB_INIT, + data: msg1.data, + }); + assert.calledWith(mm.onActionFromContent.secondCall, { + type: at.NEW_TAB_INIT, + data: msg2.data, + }); + }); + it("should simulate load for loaded ports", () => { + let msg3 = getTabDetails("foo", null, { + preloaded: true, + loaded: true, + }); + mm.loadedTabs.set(msg3.data.browser, msg3.data); + + mm.simulateMessagesForExistingTabs(); + + assert.calledWith( + mm.onActionFromContent, + { type: at.NEW_TAB_LOAD }, + "foo" + ); + }); + it("should set renderLayers on preloaded browsers after load", () => { + let msg4 = getTabDetails("foo", null, { + preloaded: true, + loaded: true, + }); + msg4.data.browser.ownerGlobal = { + STATE_MAXIMIZED: 1, + STATE_MINIMIZED: 2, + STATE_NORMAL: 3, + STATE_FULLSCREEN: 4, + windowState: 3, + isFullyOccluded: false, + }; + mm.loadedTabs.set(msg4.data.browser, msg4.data); + mm.simulateMessagesForExistingTabs(); + assert.equal(msg4.data.browser.renderLayers, true); + }); + }); + }); + describe("Message handling", () => { + describe("#getTargetById", () => { + it("should get an id if it exists", () => { + let msg = getTabDetails("foo:1"); + mm.loadedTabs.set(msg.data.browser, msg.data); + assert.equal(mm.getTargetById("foo:1"), msg.data.actor); + }); + it("should return null if the target doesn't exist", () => { + let msg = getTabDetails("foo:2"); + mm.loadedTabs.set(msg.data.browser, msg.data); + assert.equal(mm.getTargetById("bar:3"), null); + }); + }); + describe("#getPreloadedActors", () => { + it("should get a preloaded actor if it exists", () => { + let msg = getTabDetails("foo:3", null, { preloaded: true }); + mm.loadedTabs.set(msg.data.browser, msg.data); + assert.equal(mm.getPreloadedActors()[0].portID, "foo:3"); + }); + it("should get all the preloaded actors across windows if they exist", () => { + let msg = getTabDetails("foo:4a", null, { preloaded: true }); + mm.loadedTabs.set(msg.data.browser, msg.data); + msg = getTabDetails("foo:4b", null, { preloaded: true }); + mm.loadedTabs.set(msg.data.browser, msg.data); + assert.equal(mm.getPreloadedActors().length, 2); + }); + it("should return null if there is no preloaded actor", () => { + let msg = getTabDetails("foo:5"); + mm.loadedTabs.set(msg.data.browser, msg.data); + assert.equal(mm.getPreloadedActors(), null); + }); + }); + describe("#onNewTabInit", () => { + it("should dispatch a NEW_TAB_INIT action", () => { + let msg = getTabDetails("foo", "about:monkeys"); + sinon.stub(mm, "onActionFromContent"); + + mm.onNewTabInit(msg, msg.data); + + assert.calledWith(mm.onActionFromContent, { + type: at.NEW_TAB_INIT, + data: msg.data, + }); + }); + }); + describe("#onNewTabLoad", () => { + it("should dispatch a NEW_TAB_LOAD action", () => { + let msg = getTabDetails("foo", null, { preloaded: true }); + mm.loadedTabs.set(msg.data.browser, msg.data); + sinon.stub(mm, "onActionFromContent"); + mm.onNewTabLoad({ target: msg.target }, msg.data); + assert.calledWith( + mm.onActionFromContent, + { type: at.NEW_TAB_LOAD }, + "foo" + ); + }); + }); + describe("#onNewTabUnload", () => { + it("should dispatch a NEW_TAB_UNLOAD action", () => { + let msg = getTabDetails("foo"); + mm.loadedTabs.set(msg.data.browser, msg.data); + sinon.stub(mm, "onActionFromContent"); + mm.onNewTabUnload({ target: msg.target }, msg.data); + assert.calledWith( + mm.onActionFromContent, + { type: at.NEW_TAB_UNLOAD }, + "foo" + ); + }); + }); + describe("#onMessage", () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.spy(global.console, "error"); + }); + afterEach(() => sandbox.restore()); + it("return early when tab details are not present", () => { + let msg = getTabDetails("foo"); + sinon.stub(mm, "onActionFromContent"); + mm.onMessage(msg, msg.data); + assert.notCalled(mm.onActionFromContent); + }); + it("should report an error if the msg.data is missing", () => { + let msg = getTabDetails("foo"); + mm.loadedTabs.set(msg.data.browser, msg.data); + let tabDetails = msg.data; + delete msg.data; + mm.onMessage(msg, tabDetails); + assert.calledOnce(global.console.error); + }); + it("should report an error if the msg.data.type is missing", () => { + let msg = getTabDetails("foo"); + mm.loadedTabs.set(msg.data.browser, msg.data); + msg.data = "foo"; + mm.onMessage(msg, msg.data); + assert.calledOnce(global.console.error); + }); + it("should call onActionFromContent", () => { + sinon.stub(mm, "onActionFromContent"); + let msg = getTabDetails("foo"); + mm.loadedTabs.set(msg.data.browser, msg.data); + let action = { + data: { data: {}, type: "FOO" }, + target: msg.target, + }; + const expectedAction = { + type: action.data.type, + data: action.data.data, + _target: { browser: msg.data.browser }, + }; + mm.onMessage(action, msg.data); + assert.calledWith(mm.onActionFromContent, expectedAction, "foo"); + }); + }); + }); + describe("Sending and broadcasting", () => { + describe("#send", () => { + it("should send a message on the right port", () => { + let msg = getTabDetails("foo:6"); + mm.loadedTabs.set(msg.data.browser, msg.data); + const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:6"); + mm.send(action); + assert.calledWith( + msg.data.actor.sendAsyncMessage, + DEFAULT_OPTIONS.outgoingMessageName, + action + ); + }); + it("should not throw if the target isn't around", () => { + // port is not added to the channel + const action = ac.AlsoToOneContent({ type: "HELLO" }, "foo:7"); + + assert.doesNotThrow(() => mm.send(action)); + }); + }); + describe("#broadcast", () => { + it("should send a message on the channel", () => { + let msg = getTabDetails("foo:8"); + mm.loadedTabs.set(msg.data.browser, msg.data); + const action = ac.BroadcastToContent({ type: "HELLO" }); + mm.broadcast(action); + assert.calledWith( + msg.data.actor.sendAsyncMessage, + DEFAULT_OPTIONS.outgoingMessageName, + action + ); + }); + }); + describe("#preloaded browser", () => { + it("should send the message to the preloaded browser if there's data and a preloaded browser exists", () => { + let msg = getTabDetails("foo:9", null, { preloaded: true }); + mm.loadedTabs.set(msg.data.browser, msg.data); + const action = ac.AlsoToPreloaded({ type: "HELLO", data: 10 }); + mm.sendToPreloaded(action); + assert.calledWith( + msg.data.actor.sendAsyncMessage, + DEFAULT_OPTIONS.outgoingMessageName, + action + ); + }); + it("should send the message to all the preloaded browsers if there's data and they exist", () => { + let msg1 = getTabDetails("foo:10a", null, { preloaded: true }); + mm.loadedTabs.set(msg1.data.browser, msg1.data); + + let msg2 = getTabDetails("foo:10b", null, { preloaded: true }); + mm.loadedTabs.set(msg2.data.browser, msg2.data); + + mm.sendToPreloaded(ac.AlsoToPreloaded({ type: "HELLO", data: 10 })); + assert.calledOnce(msg1.data.actor.sendAsyncMessage); + assert.calledOnce(msg2.data.actor.sendAsyncMessage); + }); + it("should not send the message to the preloaded browser if there's no data and a preloaded browser does not exists", () => { + let msg = getTabDetails("foo:11"); + mm.loadedTabs.set(msg.data.browser, msg.data); + const action = ac.AlsoToPreloaded({ type: "HELLO" }); + mm.sendToPreloaded(action); + assert.notCalled(msg.data.actor.sendAsyncMessage); + }); + }); + }); + describe("Handling actions", () => { + describe("#onActionFromContent", () => { + beforeEach(() => mm.onActionFromContent({ type: "FOO" }, "foo:12")); + it("should dispatch a AlsoToMain action", () => { + assert.calledOnce(dispatch); + const [action] = dispatch.firstCall.args; + assert.equal(action.type, "FOO", "action.type"); + }); + it("should have the right fromTarget", () => { + const [action] = dispatch.firstCall.args; + assert.equal(action.meta.fromTarget, "foo:12", "meta.fromTarget"); + }); + }); + describe("#middleware", () => { + let store; + beforeEach(() => { + store = createStore(addNumberReducer, applyMiddleware(mm.middleware)); + }); + it("should just call next if no channel is found", () => { + store.dispatch({ type: "ADD", data: 10 }); + assert.equal(store.getState(), 10); + }); + it("should call .send but not affect the main store if an OnlyToOneContent action is dispatched", () => { + sinon.stub(mm, "send"); + const action = ac.OnlyToOneContent({ type: "ADD", data: 10 }, "foo"); + + store.dispatch(action); + + assert.calledWith(mm.send, action); + assert.equal(store.getState(), 0); + }); + it("should call .send and update the main store if an AlsoToOneContent action is dispatched", () => { + sinon.stub(mm, "send"); + const action = ac.AlsoToOneContent({ type: "ADD", data: 10 }, "foo"); + + store.dispatch(action); + + assert.calledWith(mm.send, action); + assert.equal(store.getState(), 10); + }); + it("should call .broadcast if the action is BroadcastToContent", () => { + sinon.stub(mm, "broadcast"); + const action = ac.BroadcastToContent({ type: "FOO" }); + + store.dispatch(action); + + assert.calledWith(mm.broadcast, action); + }); + it("should call .sendToPreloaded if the action is AlsoToPreloaded", () => { + sinon.stub(mm, "sendToPreloaded"); + const action = ac.AlsoToPreloaded({ type: "FOO" }); + + store.dispatch(action); + + assert.calledWith(mm.sendToPreloaded, action); + }); + it("should dispatch other actions normally", () => { + sinon.stub(mm, "send"); + sinon.stub(mm, "broadcast"); + sinon.stub(mm, "sendToPreloaded"); + + store.dispatch({ type: "ADD", data: 1 }); + + assert.equal(store.getState(), 1); + assert.notCalled(mm.send); + assert.notCalled(mm.broadcast); + assert.notCalled(mm.sendToPreloaded); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js new file mode 100644 index 0000000000..ebc9726def --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamPrefs.test.js @@ -0,0 +1,113 @@ +import { DefaultPrefs, Prefs } from "lib/ActivityStreamPrefs.jsm"; + +const TEST_PREF_CONFIG = new Map([ + ["foo", { value: true }], + ["bar", { value: "BAR" }], + ["baz", { value: 1 }], + ["qux", { value: "foo", value_local_dev: "foofoo" }], +]); + +describe("ActivityStreamPrefs", () => { + describe("Prefs", () => { + let p; + beforeEach(() => { + p = new Prefs(); + }); + it("should have get, set, and observe methods", () => { + assert.property(p, "get"); + assert.property(p, "set"); + assert.property(p, "observe"); + }); + describe("#observeBranch", () => { + let listener; + beforeEach(() => { + p._prefBranch = { addObserver: sinon.stub() }; + listener = { onPrefChanged: sinon.stub() }; + p.observeBranch(listener); + }); + it("should add an observer", () => { + assert.calledOnce(p._prefBranch.addObserver); + assert.calledWith(p._prefBranch.addObserver, ""); + }); + it("should store the listener", () => { + assert.equal(p._branchObservers.size, 1); + assert.ok(p._branchObservers.has(listener)); + }); + it("should call listener's onPrefChanged", () => { + p._branchObservers.get(listener)(); + + assert.calledOnce(listener.onPrefChanged); + }); + }); + describe("#ignoreBranch", () => { + let listener; + beforeEach(() => { + p._prefBranch = { + addObserver: sinon.stub(), + removeObserver: sinon.stub(), + }; + listener = {}; + p.observeBranch(listener); + }); + it("should remove the observer", () => { + p.ignoreBranch(listener); + + assert.calledOnce(p._prefBranch.removeObserver); + assert.calledWith( + p._prefBranch.removeObserver, + p._prefBranch.addObserver.firstCall.args[0] + ); + }); + it("should remove the listener", () => { + assert.equal(p._branchObservers.size, 1); + + p.ignoreBranch(listener); + + assert.equal(p._branchObservers.size, 0); + }); + }); + }); + + describe("DefaultPrefs", () => { + describe("#init", () => { + let defaultPrefs; + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + defaultPrefs = new DefaultPrefs(TEST_PREF_CONFIG); + sinon.stub(defaultPrefs, "set"); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should initialize a boolean pref", () => { + defaultPrefs.init(); + assert.calledWith(defaultPrefs.set, "foo", true); + }); + it("should not initialize a pref if a default exists", () => { + defaultPrefs.prefs.set("foo", false); + + defaultPrefs.init(); + + assert.neverCalledWith(defaultPrefs.set, "foo", true); + }); + it("should initialize a string pref", () => { + defaultPrefs.init(); + assert.calledWith(defaultPrefs.set, "bar", "BAR"); + }); + it("should initialize a integer pref", () => { + defaultPrefs.init(); + assert.calledWith(defaultPrefs.set, "baz", 1); + }); + it("should initialize a pref with value if Firefox is not a local build", () => { + defaultPrefs.init(); + assert.calledWith(defaultPrefs.set, "qux", "foo"); + }); + it("should initialize a pref with value_local_dev if Firefox is a local build", () => { + sandbox.stub(global.AppConstants, "MOZILLA_OFFICIAL").value(false); + defaultPrefs.init(); + assert.calledWith(defaultPrefs.set, "qux", "foofoo"); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js new file mode 100644 index 0000000000..f13dfd07ad --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ActivityStreamStorage.test.js @@ -0,0 +1,161 @@ +import { ActivityStreamStorage } from "lib/ActivityStreamStorage.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +let overrider = new GlobalOverrider(); + +describe("ActivityStreamStorage", () => { + let sandbox; + let indexedDB; + let storage; + beforeEach(() => { + sandbox = sinon.createSandbox(); + indexedDB = { + open: sandbox.stub().resolves({}), + deleteDatabase: sandbox.stub().resolves(), + }; + overrider.set({ IndexedDB: indexedDB }); + storage = new ActivityStreamStorage({ + storeNames: ["storage_test"], + telemetry: { handleUndesiredEvent: sandbox.stub() }, + }); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should throw if required arguments not provided", () => { + assert.throws(() => new ActivityStreamStorage({ telemetry: true })); + }); + describe(".db", () => { + it("should not throw an error when accessing db", async () => { + assert.ok(storage.db); + }); + + it("should delete and recreate the db if opening db fails", async () => { + const newDb = {}; + indexedDB.open.onFirstCall().rejects(new Error("fake error")); + indexedDB.open.onSecondCall().resolves(newDb); + + const db = await storage.db; + assert.calledOnce(indexedDB.deleteDatabase); + assert.calledTwice(indexedDB.open); + assert.equal(db, newDb); + }); + }); + describe("#getDbTable", () => { + let testStorage; + let storeStub; + beforeEach(() => { + storeStub = { + getAll: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + put: sandbox.stub().resolves(), + }; + sandbox.stub(storage, "_getStore").resolves(storeStub); + testStorage = storage.getDbTable("storage_test"); + }); + it("should reverse key value parameters for put", async () => { + await testStorage.set("key", "value"); + + assert.calledOnce(storeStub.put); + assert.calledWith(storeStub.put, "value", "key"); + }); + it("should return the correct value for get", async () => { + storeStub.get.withArgs("foo").resolves("foo"); + + const result = await testStorage.get("foo"); + + assert.calledOnce(storeStub.get); + assert.equal(result, "foo"); + }); + it("should return the correct value for getAll", async () => { + storeStub.getAll.resolves(["bar"]); + + const result = await testStorage.getAll(); + + assert.calledOnce(storeStub.getAll); + assert.deepEqual(result, ["bar"]); + }); + it("should query the correct object store", async () => { + await testStorage.get(); + + assert.calledOnce(storage._getStore); + assert.calledWithExactly(storage._getStore, "storage_test"); + }); + it("should throw if table is not found", () => { + assert.throws(() => storage.getDbTable("undefined_store")); + }); + }); + it("should get the correct objectStore when calling _getStore", async () => { + const objectStoreStub = sandbox.stub(); + indexedDB.open.resolves({ objectStore: objectStoreStub }); + + await storage._getStore("foo"); + + assert.calledOnce(objectStoreStub); + assert.calledWithExactly(objectStoreStub, "foo", "readwrite"); + }); + it("should create a db with the correct store name", async () => { + const dbStub = { + createObjectStore: sandbox.stub(), + objectStoreNames: { contains: sandbox.stub().returns(false) }, + }; + await storage.db; + + // call the cb with a stub + indexedDB.open.args[0][2](dbStub); + + assert.calledOnce(dbStub.createObjectStore); + assert.calledWithExactly(dbStub.createObjectStore, "storage_test"); + }); + it("should handle an array of object store names", async () => { + storage = new ActivityStreamStorage({ + storeNames: ["store1", "store2"], + telemetry: {}, + }); + const dbStub = { + createObjectStore: sandbox.stub(), + objectStoreNames: { contains: sandbox.stub().returns(false) }, + }; + await storage.db; + + // call the cb with a stub + indexedDB.open.args[0][2](dbStub); + + assert.calledTwice(dbStub.createObjectStore); + assert.calledWith(dbStub.createObjectStore, "store1"); + assert.calledWith(dbStub.createObjectStore, "store2"); + }); + it("should skip creating existing stores", async () => { + storage = new ActivityStreamStorage({ + storeNames: ["store1", "store2"], + telemetry: {}, + }); + const dbStub = { + createObjectStore: sandbox.stub(), + objectStoreNames: { contains: sandbox.stub().returns(true) }, + }; + await storage.db; + + // call the cb with a stub + indexedDB.open.args[0][2](dbStub); + + assert.notCalled(dbStub.createObjectStore); + }); + describe("#_requestWrapper", () => { + it("should return a successful result", async () => { + const result = await storage._requestWrapper(() => + Promise.resolve("foo") + ); + + assert.equal(result, "foo"); + assert.notCalled(storage.telemetry.handleUndesiredEvent); + }); + it("should report failures", async () => { + try { + await storage._requestWrapper(() => Promise.reject(new Error())); + } catch (e) { + assert.calledOnce(storage.telemetry.handleUndesiredEvent); + } + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js new file mode 100644 index 0000000000..e91b7fc549 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -0,0 +1,3581 @@ +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { combineReducers, createStore } from "redux"; +import { GlobalOverrider } from "test/unit/utils"; +import { DiscoveryStreamFeed } from "lib/DiscoveryStreamFeed.jsm"; +import { RecommendationProvider } from "lib/RecommendationProvider.jsm"; +import { reducers } from "common/Reducers.sys.mjs"; + +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; +import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm"; + +const CONFIG_PREF_NAME = "discoverystream.config"; +const DUMMY_ENDPOINT = "https://getpocket.cdn.mozilla.net/dummy"; +const ENDPOINTS_PREF_NAME = "discoverystream.endpoints"; +const SPOC_IMPRESSION_TRACKING_PREF = "discoverystream.spoc.impressions"; +const REC_IMPRESSION_TRACKING_PREF = "discoverystream.rec.impressions"; +const THIRTY_MINUTES = 30 * 60 * 1000; +const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week + +const FAKE_UUID = "{foo-123-foo}"; + +// eslint-disable-next-line max-statements +describe("DiscoveryStreamFeed", () => { + let feed; + let feeds; + let recommendationProvider; + let sandbox; + let fetchStub; + let clock; + let fakeNewTabUtils; + let fakePktApi; + let globals; + + const setPref = (name, value) => { + const action = { + type: at.PREF_CHANGED, + data: { + name, + value: typeof value === "object" ? JSON.stringify(value) : value, + }, + }; + feed.store.dispatch(action); + feed.onAction(action); + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Fetch + fetchStub = sandbox.stub(global, "fetch"); + + // Time + clock = sinon.useFakeTimers(); + + globals = new GlobalOverrider(); + globals.set({ + gUUIDGenerator: { generateUUID: () => FAKE_UUID }, + PersistentCache, + PersonalityProvider, + }); + + sandbox + .stub(global.Services.prefs, "getBoolPref") + .withArgs("browser.newtabpage.activity-stream.discoverystream.enabled") + .returns(true); + + recommendationProvider = new RecommendationProvider(); + recommendationProvider.store = createStore(combineReducers(reducers), {}); + feeds = { + "feeds.recommendationprovider": recommendationProvider, + }; + + // Feed + feed = new DiscoveryStreamFeed(); + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + }, + }, + }); + feed.store.feeds = { + get: name => feeds[name], + }; + global.fetch.resetHistory(); + + sandbox.stub(feed, "_maybeUpdateCachedData").resolves(); + + globals.set("setTimeout", callback => { + callback(); + }); + + fakeNewTabUtils = { + blockedLinks: { + links: [], + isBlocked: () => false, + }, + }; + globals.set("NewTabUtils", fakeNewTabUtils); + + fakePktApi = { + isUserLoggedIn: () => false, + getRecentSavesCache: () => null, + getRecentSaves: () => null, + }; + globals.set("pktApi", fakePktApi); + }); + + afterEach(() => { + clock.restore(); + sandbox.restore(); + globals.restore(); + }); + + describe("#fetchFromEndpoint", () => { + beforeEach(() => { + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + fetchStub.resolves({ + json: () => Promise.resolve("hi"), + ok: true, + }); + }); + it("should get a response", async () => { + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, "hi"); + }); + it("should not send cookies", async () => { + await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.propertyVal(fetchStub.firstCall.args[1], "credentials", "omit"); + }); + it("should allow unexpected response", async () => { + fetchStub.resolves({ ok: false }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, null); + }); + it("should disallow unexpected endpoints", async () => { + feed.store.getState = () => ({ + Prefs: { values: { [ENDPOINTS_PREF_NAME]: "https://other.site" } }, + }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, null); + }); + it("should allow multiple endpoints", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [ENDPOINTS_PREF_NAME]: `https://other.site,${DUMMY_ENDPOINT}`, + }, + }, + }); + + const response = await feed.fetchFromEndpoint(DUMMY_ENDPOINT); + + assert.equal(response, "hi"); + }); + it("should replace urls with $apiKey", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns("replaced"); + + await feed.fetchFromEndpoint( + "https://getpocket.cdn.mozilla.net/dummy?consumer_key=$apiKey" + ); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy?consumer_key=replaced", + { credentials: "omit" } + ); + }); + it("should replace locales with $locale", async () => { + feed.locale = "replaced"; + await feed.fetchFromEndpoint( + "https://getpocket.cdn.mozilla.net/dummy?locale_lang=$locale" + ); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy?locale_lang=replaced", + { credentials: "omit" } + ); + }); + it("should allow POST and with other options", async () => { + await feed.fetchFromEndpoint("https://getpocket.cdn.mozilla.net/dummy", { + method: "POST", + body: "{}", + }); + + assert.calledWithMatch( + fetchStub, + "https://getpocket.cdn.mozilla.net/dummy", + { + credentials: "omit", + method: "POST", + body: "{}", + } + ); + }); + }); + + describe("#setupPocketState", () => { + it("should setup logged in state and recent saves with cache", async () => { + fakePktApi.isUserLoggedIn = () => true; + fakePktApi.getRecentSavesCache = () => [1, 2, 3]; + sandbox.spy(feed.store, "dispatch"); + await feed.setupPocketState({}); + assert.calledTwice(feed.store.dispatch); + assert.calledWith( + feed.store.dispatch.firstCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { isUserLoggedIn: true }, + }, + {} + ) + ); + assert.calledWith( + feed.store.dispatch.secondCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { recentSaves: [1, 2, 3] }, + }, + {} + ) + ); + }); + it("should setup logged in state and recent saves without cache", async () => { + fakePktApi.isUserLoggedIn = () => true; + fakePktApi.getRecentSaves = ({ success }) => success([1, 2, 3]); + sandbox.spy(feed.store, "dispatch"); + await feed.setupPocketState({}); + assert.calledTwice(feed.store.dispatch); + assert.calledWith( + feed.store.dispatch.firstCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_POCKET_STATE_SET, + data: { isUserLoggedIn: true }, + }, + {} + ) + ); + assert.calledWith( + feed.store.dispatch.secondCall, + ac.OnlyToOneContent( + { + type: at.DISCOVERY_STREAM_RECENT_SAVES, + data: { recentSaves: [1, 2, 3] }, + }, + {} + ) + ); + }); + }); + + describe("#getOrCreateImpressionId", () => { + it("should create impression id in constructor", async () => { + assert.equal(feed._impressionId, FAKE_UUID); + }); + it("should create impression id if none exists", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns(""); + sandbox.stub(global.Services.prefs, "setCharPref").returns(); + + const result = feed.getOrCreateImpressionId(); + + assert.equal(result, FAKE_UUID); + assert.calledOnce(global.Services.prefs.setCharPref); + }); + it("should use impression id if exists", async () => { + sandbox.stub(global.Services.prefs, "getCharPref").returns("from get"); + + const result = feed.getOrCreateImpressionId(); + + assert.equal(result, "from get"); + assert.calledOnce(global.Services.prefs.getCharPref); + }); + }); + + describe("#parseGridPositions", () => { + it("should return an equivalent array for an array of non negative integers", async () => { + assert.deepEqual(feed.parseGridPositions([0, 2, 3]), [0, 2, 3]); + }); + it("should return undefined for an array containing negative integers", async () => { + assert.equal(feed.parseGridPositions([-2, 2, 3]), undefined); + }); + it("should return undefined for an undefined input", async () => { + assert.equal(feed.parseGridPositions(undefined), undefined); + }); + }); + + describe("#loadLayout", () => { + it("should fetch data and populate the cache if it is empty", async () => { + const resp = { layout: ["foo", "bar"] }; + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(fetchStub); + assert.equal(feed.cache.set.firstCall.args[0], "layout"); + assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout); + }); + it("should fetch data and populate the cache if the cached data is older than 30 mins", async () => { + const resp = { layout: ["foo", "bar"] }; + const fakeCache = { + layout: { layout: ["hello"], lastUpdated: Date.now() }, + }; + + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + clock.tick(THIRTY_MINUTES + 1); + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(fetchStub); + assert.equal(feed.cache.set.firstCall.args[0], "layout"); + assert.deepEqual(feed.cache.set.firstCall.args[1].layout, resp.layout); + }); + it("should use the cached data and not fetch if the cached data is less than 30 mins old", async () => { + const fakeCache = { + layout: { layout: ["hello"], lastUpdated: Date.now() }, + }; + + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + clock.tick(THIRTY_MINUTES - 1); + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(fetchStub); + assert.notCalled(feed.cache.set); + }); + it("should set spocs_endpoint from layout", async () => { + const resp = { layout: ["foo", "bar"], spocs: { url: "foo.com" } }; + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + fetchStub.resolves({ ok: true, json: () => Promise.resolve(resp) }); + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "foo.com" + ); + }); + it("should use local layout with hardcoded_layout being true", async () => { + feed.config.hardcoded_layout = true; + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + }); + it("should use local basic layout with hardcoded_layout and hardcoded_basic_layout being true", async () => { + feed.config.hardcoded_layout = true; + feed.config.hardcoded_basic_layout = true; + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use 1 row layout if specified", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": true, + }, + }, + }); + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use 7 row layout if specified", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.region-basic-layout": false, + }, + }, + }); + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 21); + }); + it("should use new spocs endpoint if in the config", async () => { + feed.config.spocs_endpoint = "https://spocs.getpocket.com/spocs2"; + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs2" + ); + }); + it("should use local basic layout with hardcoded_layout and FF pref hardcoded_basic_layout", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.hardcoded-basic-layout": true, + }, + }, + }); + + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + assert.notCalled(feed.fetchLayout); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 3); + }); + it("should use new spocs endpoint if in a FF pref", async () => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: false, + show_spocs: false, + layout_endpoint: DUMMY_ENDPOINT, + }), + [ENDPOINTS_PREF_NAME]: DUMMY_ENDPOINT, + "discoverystream.enabled": true, + "discoverystream.spocs-endpoint": + "https://spocs.getpocket.com/spocs2", + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs2" + ); + }); + it("should fetch local layout for invalid layout endpoint or when fetch layout fails", async () => { + feed.config.hardcoded_layout = false; + fetchStub.resolves({ ok: false }); + + await feed.loadLayout(feed.store.dispatch, true); + + assert.calledOnce(fetchStub); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs" + ); + }); + it("should return enough stories to fill a four card layout", async () => { + feed.config.hardcoded_layout = true; + + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { fourCardLayout: true }, + }, + }, + }); + + sandbox.stub(feed, "fetchLayout").returns(Promise.resolve("")); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal(layout[0].components[2].properties.items, 24); + }); + it("should create a layout with spoc and widget positions", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocPositions: "1, 2", + widgetPositions: "3, 4", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[2].spocs.positions, [ + { index: 1 }, + { index: 2 }, + ]); + assert.deepEqual(layout[0].components[2].widgets.positions, [ + { index: 3 }, + { index: 4 }, + ]); + }); + it("should create a layout with spoc position data", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocAdTypes: "1230", + spocZoneIds: "4560, 7890", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[2].placement.ad_types, [1230]); + assert.deepEqual( + layout[0].components[2].placement.zone_ids, + [4560, 7890] + ); + }); + it("should create a layout with spoc topsite position data", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocTopsitesAdTypes: "1230", + spocTopsitesZoneIds: "4560, 7890", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + + const { layout } = feed.store.getState().DiscoveryStream; + assert.deepEqual(layout[0].components[0].placement.ad_types, [1230]); + assert.deepEqual( + layout[0].components[0].placement.zone_ids, + [4560, 7890] + ); + }); + it("should create a layout with proper spoc url with a site id", async () => { + feed.config.hardcoded_layout = true; + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { + spocSiteId: "1234", + }, + }, + }, + }); + + await feed.loadLayout(feed.store.dispatch); + const { spocs } = feed.store.getState().DiscoveryStream; + assert.deepEqual( + spocs.spocs_endpoint, + "https://spocs.getpocket.com/spocs?site=1234" + ); + }); + }); + + describe("#updatePlacements", () => { + it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS", () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + const fakeComponents = { + components: [ + { placement: { name: "first" }, spocs: {} }, + { placement: { name: "second" }, spocs: {} }, + ], + }; + const fakeLayout = [fakeComponents]; + + feed.updatePlacements(feed.store.dispatch, fakeLayout); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + data: { placements: [{ name: "first" }, { name: "second" }] }, + meta: { isStartup: false }, + }); + }); + it("should dispatch DISCOVERY_STREAM_SPOCS_PLACEMENTS with prefs array", () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true, withPref: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + const fakeComponents = { + components: [ + { placement: { name: "withPref" }, spocs: { prefs: ["withPref"] } }, + { placement: { name: "withoutPref1" }, spocs: {} }, + { + placement: { name: "withoutPref2" }, + spocs: { prefs: ["whatever"] }, + }, + { placement: { name: "withoutPref3" }, spocs: { prefs: [] } }, + ], + }; + const fakeLayout = [fakeComponents]; + + feed.updatePlacements(feed.store.dispatch, fakeLayout); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: "DISCOVERY_STREAM_SPOCS_PLACEMENTS", + data: { placements: [{ name: "withPref" }, { name: "withoutPref1" }] }, + meta: { isStartup: false }, + }); + }); + it("should fire update placements from loadLayout", async () => { + sandbox.spy(feed, "updatePlacements"); + + await feed.loadLayout(feed.store.dispatch); + + assert.calledOnce(feed.updatePlacements); + }); + }); + + describe("#placementsForEach", () => { + it("should forEach through placements", () => { + feed.store.getState = () => ({ + DiscoveryStream: { + spocs: { + placements: [{ name: "first" }, { name: "second" }], + }, + }, + }); + + let items = []; + + feed.placementsForEach(item => items.push(item.name)); + + assert.deepEqual(items, ["first", "second"]); + }); + }); + + describe("#loadLayoutEndPointUsingPref", () => { + it("should return endpoint if valid key", async () => { + const endpoint = feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=$apiKey", + "test_key_val" + ); + assert.equal( + "https://somedomain.org/stories?consumer_key=test_key_val", + endpoint + ); + }); + + it("should throw error if key is empty", async () => { + assert.throws(() => { + feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=$apiKey", + "" + ); + }); + }); + + it("should return url if $apiKey is missing in layout_endpoint", async () => { + const endpoint = feed.finalLayoutEndpoint( + "https://somedomain.org/stories?consumer_key=", + "test_key_val" + ); + assert.equal("https://somedomain.org/stories?consumer_key=", endpoint); + }); + + it("should update config layout_endpoint based on api_key_pref value", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + api_key_pref: "test_api_key_pref", + enabled: true, + layout_endpoint: + "https://somedomain.org/stories?consumer_key=$apiKey", + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.equal( + "https://somedomain.org/stories?consumer_key=test_api_key_val", + feed.config.layout_endpoint + ); + }); + + it("should not update config layout_endpoint if api_key_pref missing", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + layout_endpoint: + "https://somedomain.org/stories?consumer_key=1234", + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.notCalled(global.Services.prefs.getCharPref); + assert.equal( + "https://somedomain.org/stories?consumer_key=1234", + feed.config.layout_endpoint + ); + }); + + it("should not set config layout_endpoint if layout_endpoint missing in prefs", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + [CONFIG_PREF_NAME]: JSON.stringify({ + enabled: true, + }), + }, + }, + }); + sandbox + .stub(global.Services.prefs, "getCharPref") + .returns("test_api_key_val"); + assert.notCalled(global.Services.prefs.getCharPref); + assert.isUndefined(feed.config.layout_endpoint); + }); + }); + + describe("#loadComponentFeeds", () => { + let fakeCache; + let fakeDiscoveryStream; + beforeEach(() => { + fakeDiscoveryStream = { + Prefs: {}, + DiscoveryStream: { + layout: [ + { components: [{ feed: { url: "foo.com" } }] }, + { components: [{}] }, + {}, + ], + }, + }; + fakeCache = {}; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should not dispatch updates when layout is not defined", async () => { + fakeDiscoveryStream = { + DiscoveryStream: {}, + }; + feed.store.getState.returns(fakeDiscoveryStream); + sandbox.spy(feed.store, "dispatch"); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.notCalled(feed.store.dispatch); + }); + + it("should populate feeds cache", async () => { + fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "feeds", { + "foo.com": { data: "data", lastUpdated: 0 }, + }); + }); + + it("should send feed update events with new feed data", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.spy(feed.store, "dispatch"); + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledWith(feed.store.dispatch.firstCall, { + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { feed: { data: { status: "failed" } }, url: "foo.com" }, + meta: { isStartup: false }, + }); + assert.calledWith(feed.store.dispatch.secondCall, { + type: at.DISCOVERY_STREAM_FEEDS_UPDATE, + meta: { isStartup: false }, + }); + }); + + it("should return number of promises equal to unique urls", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(global.Promise, "all").resolves(); + fakeDiscoveryStream = { + DiscoveryStream: { + layout: [ + { + components: [ + { feed: { url: "foo.com" } }, + { feed: { url: "bar.com" } }, + ], + }, + { components: [{ feed: { url: "foo.com" } }] }, + {}, + { components: [{ feed: { url: "baz.com" } }] }, + ], + }, + }; + feed.store.getState.returns(fakeDiscoveryStream); + + await feed.loadComponentFeeds(feed.store.dispatch); + + assert.calledOnce(global.Promise.all); + const { args } = global.Promise.all.firstCall; + assert.equal(args[0].length, 3); + }); + }); + + describe("#getComponentFeed", () => { + it("should fetch fresh feed data if cache is empty", async () => { + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data.recommendations, "data"); + }); + it("should fetch fresh feed data if cache is old", async () => { + const fakeCache = { feeds: { "foo.com": { lastUpdated: Date.now() } } }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + clock.tick(THIRTY_MINUTES + 1); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data.recommendations, "data"); + }); + it("should return feed data from cache if it is fresh", async () => { + const fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").resolves(fakeCache); + sandbox.stub(feed, "fetchFromEndpoint").resolves("old data"); + clock.tick(THIRTY_MINUTES - 1); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.equal(feedResp.data, "data"); + }); + it("should return null if no response was received", async () => { + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + + const feedResp = await feed.getComponentFeed("foo.com"); + + assert.deepEqual(feedResp, { data: { status: "failed" } }); + }); + }); + + describe("#personalizationOverride", () => { + it("should dispatch setPref", async () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.personalization.enabled": true, + }, + }, + }); + + feed.personalizationOverride(true); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: "discoverystream.personalization.override", + value: true, + }, + type: at.SET_PREF, + }); + }); + it("should dispatch CLEAR_PREF", async () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.personalization.enabled": true, + "discoverystream.personalization.override": true, + }, + }, + }); + + feed.personalizationOverride(false); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: "discoverystream.personalization.override", + }, + type: at.CLEAR_PREF, + }); + }); + }); + + describe("#loadSpocs", () => { + beforeEach(() => { + feed._prefCache = { + config: { + api_key_pref: "", + }, + }; + + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + }); + it("should not fetch or update cache if no spocs endpoint is defined", async () => { + feed.store.dispatch( + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT, + data: "", + }) + ); + + sandbox.spy(feed.cache, "set"); + + await feed.loadSpocs(feed.store.dispatch); + + assert.notCalled(global.fetch); + assert.notCalled(feed.cache.set); + }); + it("should fetch fresh spocs data if cache is empty", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "data" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "spocs", { + spocs: { placement: "data" }, + lastUpdated: 0, + }); + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "data" + ); + }); + it("should fetch fresh data if cache is old", async () => { + const cachedSpoc = { + spocs: { placement: "old" }, + lastUpdated: Date.now(), + }; + const cachedData = { spocs: cachedSpoc }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + clock.tick(THIRTY_MINUTES + 1); + + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "new" + ); + }); + it("should return spoc data from cache if it is fresh", async () => { + const cachedSpoc = { + spocs: { placement: "old" }, + lastUpdated: Date.now(), + }; + const cachedData = { spocs: cachedSpoc }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(cachedData)); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ placement: "new" }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + clock.tick(THIRTY_MINUTES - 1); + + await feed.loadSpocs(feed.store.dispatch); + + assert.equal( + feed.store.getState().DiscoveryStream.spocs.data.placement, + "old" + ); + }); + it("should properly transform spocs using placements", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ spocs: { items: [{ id: "data" }] } }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledWith(feed.cache.set, "spocs", { + spocs: { + spocs: { + context: "", + title: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + }, + lastUpdated: 0, + }); + + assert.deepEqual( + feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], + { id: "data", score: 1 } + ); + }); + it("should normalizeSpocsItems for older spoc data", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ spocs: [{ id: "data" }] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual( + feed.store.getState().DiscoveryStream.spocs.data.spocs.items[0], + { id: "data", score: 1 } + ); + }); + it("should call personalizationVersionOverride with feature_flags", async () => { + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "personalizationOverride").returns(); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ settings: { feature_flags: {} }, spocs: [{ id: "data" }] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.loadSpocs(feed.store.dispatch); + + assert.calledOnce(feed.personalizationOverride); + }); + it("should return expected data if normalizeSpocsItems returns no spoc data", async () => { + // We don't need this for just this test, we are setting placements manually. + feed.getPlacements.restore(); + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox + .stub(feed, "fetchFromEndpoint") + .resolves({ placement1: [{ id: "data" }], placement2: [] }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + const fakeComponents = { + components: [ + { placement: { name: "placement1" }, spocs: {} }, + { placement: { name: "placement2" }, spocs: {} }, + ], + }; + feed.updatePlacements(feed.store.dispatch, [fakeComponents]); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, { + placement1: { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + placement2: { + title: "", + context: "", + items: [], + }, + }); + }); + it("should use title and context on spoc data", async () => { + // We don't need this for just this test, we are setting placements manually. + feed.getPlacements.restore(); + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + sandbox.stub(feed.cache, "get").returns(Promise.resolve()); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + placement1: { + title: "title", + context: "context", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data" }], + }, + }); + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + const fakeComponents = { + components: [{ placement: { name: "placement1" }, spocs: {} }], + }; + feed.updatePlacements(feed.store.dispatch, [fakeComponents]); + + await feed.loadSpocs(feed.store.dispatch); + + assert.deepEqual(feed.store.getState().DiscoveryStream.spocs.data, { + placement1: { + title: "title", + context: "context", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "data", score: 1 }], + }, + }); + }); + }); + + describe("#normalizeSpocsItems", () => { + it("should return correct data if new data passed in", async () => { + const spocs = { + title: "title", + context: "context", + sponsor: "sponsor", + sponsored_by_override: "override", + items: [{ id: "id" }], + }; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, spocs); + }); + it("should return normalized data if new data passed in without title or context", async () => { + const spocs = { + items: [{ id: "id" }], + }; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "id" }], + }); + }); + it("should return normalized data if old data passed in", async () => { + const spocs = [{ id: "id" }]; + const result = feed.normalizeSpocsItems(spocs); + assert.deepEqual(result, { + title: "", + context: "", + sponsor: "", + sponsored_by_override: undefined, + items: [{ id: "id" }], + }); + }); + }); + + describe("#showSpocs", () => { + it("should return true from showSpocs if showSponsoredStories is false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => false, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => true, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return true from showSpocs if showSponsoredTopsites is false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => false, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return true from showSpocs if both are true", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => true, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => true, + }); + assert.isTrue(feed.showSpocs); + }); + it("should return false from showSpocs if both are false", async () => { + Object.defineProperty(feed, "showSponsoredStories", { + get: () => false, + }); + Object.defineProperty(feed, "showSponsoredTopsites", { + get: () => false, + }); + assert.isFalse(feed.showSpocs); + }); + }); + + describe("#showSponsoredStories", () => { + it("should return false from showSponsoredStories if user pref showSponsored is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: false } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return false from showSponsoredStories if DiscoveryStream pref show_spocs is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: false }), + }); + + assert.isFalse(feed.showSponsoredStories); + }); + it("should return true from showSponsoredStories if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsored: true } }, + }); + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + + assert.isTrue(feed.showSponsoredStories); + }); + }); + + describe("#showSponsoredTopsites", () => { + it("should return false from showSponsoredTopsites if user pref showSponsoredTopSites is false", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsoredTopSites: false } }, + DiscoveryStream: { + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }, + }); + assert.isFalse(feed.showSponsoredTopsites); + }); + it("should return true from showSponsoredTopsites if user pref showSponsoredTopSites is true", async () => { + feed.store.getState = () => ({ + Prefs: { values: { showSponsoredTopSites: true } }, + DiscoveryStream: { + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }, + }); + assert.isTrue(feed.showSponsoredTopsites); + }); + }); + + describe("#showStories", () => { + it("should return false from showStories if user pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": false, + "feeds.system.topstories": true, + }, + }, + }); + assert.isFalse(feed.showStories); + }); + it("should return false from showStories if system pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": false, + }, + }, + }); + assert.isFalse(feed.showStories); + }); + it("should return true from showStories if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + assert.isTrue(feed.showStories); + }); + }); + + describe("#showTopsites", () => { + it("should return false from showTopsites if user pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": false, + "feeds.system.topsites": true, + }, + }, + }); + assert.isFalse(feed.showTopsites); + }); + it("should return false from showTopsites if system pref is false", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": true, + "feeds.system.topsites": false, + }, + }, + }); + assert.isFalse(feed.showTopsites); + }); + it("should return true from showTopsites if both prefs are true", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "feeds.topsites": true, + "feeds.system.topsites": true, + }, + }, + }); + assert.isTrue(feed.showTopsites); + }); + }); + + describe("#clearSpocs", () => { + let defaultState; + let DiscoveryStream; + let Prefs; + beforeEach(() => { + Object.defineProperty(feed, "config", { + get: () => ({ show_spocs: true }), + }); + DiscoveryStream = { + layout: [], + spocs: { + placements: [{ name: "sponsored-topsites" }], + }, + }; + Prefs = { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + "feeds.topsites": true, + "feeds.system.topsites": true, + showSponsoredTopSites: true, + showSponsored: true, + }, + }; + defaultState = { + DiscoveryStream, + Prefs, + }; + feed.store.getState = () => defaultState; + }); + it("should not fail with no endpoint", async () => { + sandbox.stub(feed.store, "getState").returns({ + Prefs: { + values: { "discoverystream.endpointSpocsClear": null }, + }, + }); + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + + await feed.clearSpocs(); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should call DELETE with endpoint", async () => { + sandbox.stub(feed.store, "getState").returns({ + Prefs: { + values: { + "discoverystream.endpointSpocsClear": "https://spocs/user", + }, + }, + }); + sandbox.stub(feed, "fetchFromEndpoint").resolves(null); + feed._impressionId = "1234"; + + await feed.clearSpocs(); + + assert.equal( + feed.fetchFromEndpoint.firstCall.args[0], + "https://spocs/user" + ); + assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "DELETE"); + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].body, + '{"pocket_id":"1234"}' + ); + }); + it("should properly call clearSpocs when sponsored content is changed", async () => { + sandbox.stub(feed, "clearSpocs").returns(Promise.resolve()); + //sandbox.stub(feed, "updatePlacements").returns(); + sandbox.stub(feed, "loadSpocs").returns(); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.notCalled(feed.clearSpocs); + + Prefs.values.showSponsoredTopSites = false; + Prefs.values.showSponsored = false; + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.clearSpocs); + }); + it("should call clearSpocs when top stories and top sites is turned off", async () => { + sandbox.stub(feed, "clearSpocs").returns(Promise.resolve()); + Prefs.values["feeds.section.topstories"] = false; + Prefs.values["feeds.topsites"] = false; + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.section.topstories" }, + }); + + assert.calledOnce(feed.clearSpocs); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.topsites" }, + }); + + assert.calledTwice(feed.clearSpocs); + }); + }); + + describe("#rotate", () => { + it("should move seen first story to the back of the response", () => { + const recsExpireTime = 5600; + const feedResponse = { + recommendations: [ + { + id: "first", + }, + { + id: "second", + }, + { + id: "third", + }, + { + id: "fourth", + }, + ], + settings: { + recsExpireTime, + }, + }; + const fakeImpressions = { + first: Date.now() - recsExpireTime * 1000, + third: Date.now(), + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + + const result = feed.rotate( + feedResponse.recommendations, + feedResponse.settings.recsExpireTime + ); + + assert.equal(result[3].id, "first"); + }); + }); + + describe("#reset", () => { + it("should fire all reset based functions", async () => { + sandbox.stub(global.Services.obs, "removeObserver").returns(); + + sandbox.stub(feed, "resetDataPrefs").returns(); + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "resetState").returns(); + + feed.loaded = true; + + await feed.reset(); + + assert.calledOnce(feed.resetDataPrefs); + assert.calledOnce(feed.resetCache); + assert.calledOnce(feed.resetState); + assert.calledOnce(global.Services.obs.removeObserver); + }); + }); + + describe("#resetCache", () => { + it("should set .layout, .feeds .spocs and .personalization to {}", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + + await feed.resetCache(); + + assert.callCount(feed.cache.set, 4); + const firstCall = feed.cache.set.getCall(0); + const secondCall = feed.cache.set.getCall(1); + const thirdCall = feed.cache.set.getCall(2); + const fourthCall = feed.cache.set.getCall(3); + assert.deepEqual(firstCall.args, ["layout", {}]); + assert.deepEqual(secondCall.args, ["feeds", {}]); + assert.deepEqual(thirdCall.args, ["spocs", {}]); + assert.deepEqual(fourthCall.args, ["personalization", {}]); + }); + }); + + describe("#scoreItems", () => { + it("should return initial data if spocs are empty", async () => { + const { data: result } = await feed.scoreItems([]); + + assert.equal(result.length, 0); + }); + + it("should sort based on item_score", async () => { + const { data: result } = await feed.scoreItems([ + { id: 2, flight_id: 2, item_score: 0.8 }, + { id: 4, flight_id: 4, item_score: 0.5 }, + { id: 3, flight_id: 3, item_score: 0.7 }, + { id: 1, flight_id: 1, item_score: 0.9 }, + ]); + + assert.deepEqual(result, [ + { id: 1, flight_id: 1, item_score: 0.9, score: 0.9 }, + { id: 2, flight_id: 2, item_score: 0.8, score: 0.8 }, + { id: 3, flight_id: 3, item_score: 0.7, score: 0.7 }, + { id: 4, flight_id: 4, item_score: 0.5, score: 0.5 }, + ]); + }); + + it("should sort based on priority", async () => { + const { data: result } = await feed.scoreItems([ + { id: 6, flight_id: 6, priority: 2, item_score: 0.7 }, + { id: 2, flight_id: 3, priority: 1, item_score: 0.2 }, + { id: 4, flight_id: 4, item_score: 0.6 }, + { id: 5, flight_id: 5, priority: 2, item_score: 0.8 }, + { id: 3, flight_id: 3, item_score: 0.8 }, + { id: 1, flight_id: 1, priority: 1, item_score: 0.3 }, + ]); + + assert.deepEqual(result, [ + { + id: 1, + flight_id: 1, + priority: 1, + score: 0.3, + item_score: 0.3, + }, + { + id: 2, + flight_id: 3, + priority: 1, + score: 0.2, + item_score: 0.2, + }, + { + id: 5, + flight_id: 5, + priority: 2, + score: 0.8, + item_score: 0.8, + }, + { + id: 6, + flight_id: 6, + priority: 2, + score: 0.7, + item_score: 0.7, + }, + { id: 3, flight_id: 3, item_score: 0.8, score: 0.8 }, + { id: 4, flight_id: 4, item_score: 0.6, score: 0.6 }, + ]); + }); + + it("should add a score prop to spocs", async () => { + const { data: result } = await feed.scoreItems([ + { flight_id: 1, item_score: 0.9 }, + ]); + + assert.equal(result[0].score, 0.9); + }); + }); + + describe("#filterBlocked", () => { + it("should return initial data if spocs are empty", () => { + const { data: result } = feed.filterBlocked([]); + + assert.equal(result.length, 0); + }); + it("should return initial data if links are not blocked", () => { + const { data: result } = feed.filterBlocked([ + { url: "https://foo.com" }, + { url: "test.com" }, + ]); + assert.equal(result.length, 2); + }); + it("should return initial recommendations data if links are not blocked", () => { + const { data: result } = feed.filterBlocked([ + { url: "https://foo.com" }, + { url: "test.com" }, + ]); + assert.equal(result.length, 2); + }); + it("filterRecommendations based on blockedlist by passing feed data", () => { + fakeNewTabUtils.blockedLinks.links = [{ url: "https://foo.com" }]; + fakeNewTabUtils.blockedLinks.isBlocked = site => + fakeNewTabUtils.blockedLinks.links[0].url === site.url; + + const result = feed.filterRecommendations({ + lastUpdated: 4, + data: { + recommendations: [{ url: "https://foo.com" }, { url: "test.com" }], + }, + }); + + assert.equal(result.lastUpdated, 4); + assert.lengthOf(result.data.recommendations, 1); + assert.equal(result.data.recommendations[0].url, "test.com"); + assert.notInclude( + result.data.recommendations, + fakeNewTabUtils.blockedLinks.links[0] + ); + }); + }); + + describe("#frequencyCapSpocs", () => { + it("should return filtered out spocs based on frequency caps", () => { + const fakeSpocs = [ + { + id: 1, + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ]; + const fakeImpressions = { + seen: [Date.now() - 1], + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + + const { data: result, filtered } = feed.frequencyCapSpocs(fakeSpocs); + + assert.equal(result.length, 1); + assert.equal(result[0].flight_id, "not-seen"); + assert.deepEqual(filtered, [fakeSpocs[0]]); + }); + it("should return simple structure and do nothing with no spocs", () => { + const { data: result, filtered } = feed.frequencyCapSpocs([]); + + assert.equal(result.length, 0); + assert.equal(filtered.length, 0); + }); + }); + + describe("#migrateFlightId", () => { + it("should migrate campaign to flight if no flight exists", () => { + const fakeSpocs = [ + { + id: 1, + campaign_id: "campaign", + caps: { + lifetime: 3, + campaign: { + count: 1, + period: 1, + }, + }, + }, + ]; + const { data: result } = feed.migrateFlightId(fakeSpocs); + + assert.deepEqual(result[0], { + id: 1, + flight_id: "campaign", + campaign_id: "campaign", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + campaign: { + count: 1, + period: 1, + }, + }, + }); + }); + it("should not migrate campaign to flight if caps or id don't exist", () => { + const fakeSpocs = [{ id: 1 }]; + const { data: result } = feed.migrateFlightId(fakeSpocs); + + assert.deepEqual(result[0], { id: 1 }); + }); + it("should return simple structure and do nothing with no spocs", () => { + const { data: result } = feed.migrateFlightId([]); + + assert.equal(result.length, 0); + }); + }); + + describe("#isBelowFrequencyCap", () => { + it("should return true if there are no flight impressions", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isTrue(result); + }); + it("should return true if there are no flight caps", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 3, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isTrue(result); + }); + + it("should return false if lifetime cap is hit", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 1, + flight: { + count: 3, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isFalse(result); + }); + + it("should return false if time based cap is hit", () => { + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const fakeSpoc = { + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }; + + const result = feed.isBelowFrequencyCap(fakeImpressions, fakeSpoc); + + assert.isFalse(result); + }); + }); + + describe("#retryFeed", () => { + it("should retry a feed fetch", async () => { + sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({})); + sandbox.stub(feed, "filterRecommendations").returns({}); + sandbox.spy(feed.store, "dispatch"); + + await feed.retryFeed({ url: "https://feed.com" }); + + assert.calledOnce(feed.getComponentFeed); + assert.calledOnce(feed.filterRecommendations); + assert.calledOnce(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + "DISCOVERY_STREAM_FEED_UPDATE" + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + feed: {}, + url: "https://feed.com", + }); + }); + }); + + describe("#recordFlightImpression", () => { + it("should return false if time based cap is hit", () => { + sandbox.stub(feed, "readDataPref").returns({}); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.recordFlightImpression("seen"); + + assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, { + seen: [0], + }); + }); + }); + + describe("#recordBlockFlightId", () => { + it("should call writeDataPref with new flight id added", () => { + sandbox.stub(feed, "readDataPref").returns({ 1234: 1 }); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.recordBlockFlightId("5678"); + + assert.calledOnce(feed.readDataPref); + assert.calledWith(feed.writeDataPref, "discoverystream.flight.blocks", { + 1234: 1, + 5678: 1, + }); + }); + }); + + describe("#cleanUpFlightImpressionPref", () => { + it("should remove flight-3 because it is no longer being used", async () => { + const fakeSpocs = { + spocs: { + items: [ + { + flight_id: "flight-1", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + flight_id: "flight-2", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + const fakeImpressions = { + "flight-2": [Date.now() - 1], + "flight-3": [Date.now() - 1], + }; + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.cleanUpFlightImpressionPref(fakeSpocs); + + assert.calledWith(feed.writeDataPref, SPOC_IMPRESSION_TRACKING_PREF, { + "flight-2": [-1], + }); + }); + }); + + describe("#recordTopRecImpressions", () => { + it("should add a rec id to the rec impression pref", () => { + sandbox.stub(feed, "readDataPref").returns({}); + sandbox.stub(feed, "writeDataPref"); + + feed.recordTopRecImpressions("rec"); + + assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, { + rec: 0, + }); + }); + it("should not add an impression if it already exists", () => { + sandbox.stub(feed, "readDataPref").returns({ rec: 4 }); + sandbox.stub(feed, "writeDataPref"); + + feed.recordTopRecImpressions("rec"); + + assert.notCalled(feed.writeDataPref); + }); + }); + + describe("#cleanUpTopRecImpressionPref", () => { + it("should remove recs no longer being used", () => { + const newFeeds = { + "https://foo.com": { + data: { + recommendations: [ + { + id: "rec1", + }, + { + id: "rec2", + }, + ], + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "rec3", + }, + { + id: "rec4", + }, + ], + }, + }, + }; + const fakeImpressions = { + rec2: Date.now() - 1, + rec3: Date.now() - 1, + rec5: Date.now() - 1, + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.stub(feed, "writeDataPref").returns(); + + feed.cleanUpTopRecImpressionPref(newFeeds); + + assert.calledWith(feed.writeDataPref, REC_IMPRESSION_TRACKING_PREF, { + rec2: -1, + rec3: -1, + }); + }); + }); + + describe("#writeDataPref", () => { + it("should call Services.prefs.setStringPref", () => { + sandbox.spy(feed.store, "dispatch"); + const fakeImpressions = { + foo: [Date.now() - 1], + bar: [Date.now() - 1], + }; + + feed.writeDataPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: SPOC_IMPRESSION_TRACKING_PREF, + value: JSON.stringify(fakeImpressions), + }, + type: at.SET_PREF, + }); + }); + }); + + describe("#addEndpointQuery", () => { + const url = "https://spocs.getpocket.com/spocs"; + + it("should return same url with no query", () => { + const result = feed.addEndpointQuery(url, ""); + assert.equal(result, url); + }); + + it("should add multiple query params to standard url", () => { + const params = "?first=first&second=second"; + const result = feed.addEndpointQuery(url, params); + assert.equal(result, url + params); + }); + + it("should add multiple query params to url with a query already", () => { + const params = "first=first&second=second"; + const initialParams = "?zero=zero"; + const result = feed.addEndpointQuery( + `${url}${initialParams}`, + `?${params}` + ); + assert.equal(result, `${url}${initialParams}&${params}`); + }); + }); + + describe("#readDataPref", () => { + it("should return what's in Services.prefs.getStringPref", () => { + const fakeImpressions = { + foo: [Date.now() - 1], + bar: [Date.now() - 1], + }; + setPref(SPOC_IMPRESSION_TRACKING_PREF, fakeImpressions); + + const result = feed.readDataPref(SPOC_IMPRESSION_TRACKING_PREF); + + assert.deepEqual(result, fakeImpressions); + }); + }); + + describe("#setupPrefs", () => { + it("should call setupPrefs", async () => { + sandbox.spy(feed, "setupPrefs"); + feed.onAction({ + type: at.INIT, + }); + assert.calledOnce(feed.setupPrefs); + }); + it("should dispatch to at.DISCOVERY_STREAM_PREFS_SETUP with proper data", async () => { + sandbox.spy(feed.store, "dispatch"); + globals.set("ExperimentAPI", { + getExperimentMetaData: () => ({ + slug: "experimentId", + branch: { + slug: "branchId", + }, + }), + getRolloutMetaData: () => ({}), + }); + global.Services.prefs.getBoolPref + .withArgs("extensions.pocket.enabled") + .returns(true); + feed.store.getState = () => ({ + Prefs: { + values: { + region: "CA", + pocketConfig: { + recentSavesEnabled: true, + hideDescriptions: false, + hideDescriptionsRegions: "US,CA,GB", + compactImages: true, + imageGradient: true, + newSponsoredLabel: true, + titleLines: "1", + descLines: "1", + readTime: true, + saveToPocketCard: false, + saveToPocketCardRegions: "US,CA,GB", + }, + }, + }, + }); + feed.setupPrefs(); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + utmSource: "pocket-newtab", + utmCampaign: "experimentId", + utmContent: "branchId", + }); + assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, { + recentSavesEnabled: true, + pocketButtonEnabled: true, + saveToPocketCard: true, + hideDescriptions: true, + compactImages: true, + imageGradient: true, + newSponsoredLabel: true, + titleLines: "1", + descLines: "1", + readTime: true, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_IMPRESSION_STATS", () => { + it("should call recordTopRecImpressions from DISCOVERY_STREAM_IMPRESSION_STATS", async () => { + sandbox.stub(feed, "recordTopRecImpressions").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_IMPRESSION_STATS, + data: { tiles: [{ id: "seen" }] }, + }); + + assert.calledWith(feed.recordTopRecImpressions, "seen"); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_SPOC_IMPRESSION", () => { + beforeEach(() => { + const data = { + spocs: { + items: [ + { + id: 1, + flight_id: "seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + }, + }, + }); + }); + + it("should call dispatch to ac.AlsoToPreloaded with filtered spoc data", async () => { + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = { + seen: [Date.now() - 1], + }; + const result = { + spocs: { + items: [ + { + id: 2, + flight_id: "not-seen", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: "seen" }, + }); + + assert.deepEqual( + feed.store.dispatch.secondCall.args[0].data.spocs, + result + ); + }); + it("should not call dispatch to ac.AlsoToPreloaded if spocs were not changed by frequency capping", async () => { + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = {}; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flight_id: "seen" }, + }); + + assert.notCalled(feed.store.dispatch); + }); + it("should attempt feq cap on valid spocs with placements on impression", async () => { + sandbox.restore(); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + const fakeImpressions = {}; + sandbox.stub(feed, "recordFlightImpression").returns(); + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + sandbox.spy(feed.store, "dispatch"); + sandbox.spy(feed, "frequencyCapSpocs"); + + const data = { + spocs: { + items: [ + { + id: 2, + flight_id: "seen-2", + caps: { + lifetime: 3, + flight: { + count: 1, + period: 1, + }, + }, + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + placements: [{ name: "spocs" }, { name: "notSpocs" }], + }, + }, + }); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flight_id: "doesn't matter" }, + }); + + assert.calledOnce(feed.frequencyCapSpocs); + assert.calledWith(feed.frequencyCapSpocs, data.spocs.items); + }); + }); + + describe("#onAction: PLACES_LINK_BLOCKED", () => { + beforeEach(() => { + const data = { + spocs: { + items: [ + { + id: 1, + flight_id: "foo", + url: "foo.com", + }, + { + id: 2, + flight_id: "bar", + url: "bar.com", + }, + ], + }, + }; + sandbox.stub(feed.store, "getState").returns({ + DiscoveryStream: { + spocs: { + data, + placements: [{ name: "spocs" }], + }, + }, + }); + }); + + it("should call dispatch if found a blocked spoc", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "foo.com" }, + }); + + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data.url, + "foo.com" + ); + }); + it("should dispatch once if the blocked is not a SPOC", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "not_a_spoc.com" }, + }); + + assert.calledOnce(feed.store.dispatch); + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data.url, + "not_a_spoc.com" + ); + }); + it("should dispatch a DISCOVERY_STREAM_SPOC_BLOCKED for a blocked spoc", async () => { + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "foo.com" }, + }); + + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + "DISCOVERY_STREAM_SPOC_BLOCKED" + ); + }); + }); + + describe("#onAction: BLOCK_URL", () => { + it("should call recordBlockFlightId whith BLOCK_URL", async () => { + sandbox.stub(feed, "recordBlockFlightId").returns(); + + await feed.onAction({ + type: at.BLOCK_URL, + data: [ + { + flight_id: "1234", + }, + ], + }); + + assert.calledWith(feed.recordBlockFlightId, "1234"); + }); + }); + + describe("#onAction: INIT", () => { + it("should be .loaded=false before initialization", () => { + assert.isFalse(feed.loaded); + }); + it("should load data and set .loaded=true if config.enabled is true", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + setPref(CONFIG_PREF_NAME, { enabled: true }); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + + await feed.onAction({ type: at.INIT }); + + assert.calledOnce(feed.loadLayout); + assert.isTrue(feed.loaded); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_SET_VALUE", async () => { + it("should add the new value to the pref without changing the existing values", async () => { + sandbox.spy(feed.store, "dispatch"); + setPref(CONFIG_PREF_NAME, { enabled: true, other: "value" }); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name: "layout_endpoint", value: "foo.com" }, + }); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: CONFIG_PREF_NAME, + value: JSON.stringify({ + enabled: true, + other: "value", + layout_endpoint: "foo.com", + }), + }, + type: at.SET_PREF, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_POCKET_STATE_INIT", async () => { + it("should call setupPocketState", async () => { + sandbox.spy(feed, "setupPocketState"); + feed.onAction({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + meta: { fromTarget: {} }, + }); + assert.calledOnce(feed.setupPocketState); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET", async () => { + it("should call configReset", async () => { + sandbox.spy(feed, "configReset"); + feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_RESET, + }); + assert.calledOnce(feed.configReset); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS", async () => { + it("Should dispatch CLEAR_PREF with pref name", async () => { + sandbox.spy(feed.store, "dispatch"); + await feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }); + + assert.calledWithMatch(feed.store.dispatch, { + data: { + name: CONFIG_PREF_NAME, + }, + type: at.CLEAR_PREF, + }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => { + it("should call retryFeed", async () => { + sandbox.spy(feed, "retryFeed"); + feed.onAction({ + type: at.DISCOVERY_STREAM_RETRY_FEED, + data: { feed: { url: "https://feed.com" } }, + }); + assert.calledOnce(feed.retryFeed); + assert.calledWith(feed.retryFeed, { url: "https://feed.com" }); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => { + it("should call this.loadLayout if config.enabled changes to true ", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // First initialize + await feed.onAction({ type: at.INIT }); + assert.isFalse(feed.loaded); + + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledOnce(feed.loadLayout); + assert.calledOnce(feed.resetCache); + assert.isTrue(feed.loaded); + }); + it("should clear the cache if a config change happens and config.enabled is true", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledOnce(feed.resetCache); + }); + it("should dispatch DISCOVERY_STREAM_LAYOUT_RESET from DISCOVERY_STREAM_CONFIG_CHANGE", async () => { + sandbox.stub(feed, "resetDataPrefs"); + sandbox.stub(feed, "resetCache").resolves(); + sandbox.stub(feed, "enable").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + sandbox.spy(feed.store, "dispatch"); + + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.calledWithMatch(feed.store.dispatch, { + type: at.DISCOVERY_STREAM_LAYOUT_RESET, + }); + }); + it("should not call this.loadLayout if config.enabled changes to false", async () => { + sandbox.stub(feed.cache, "set").returns(Promise.resolve()); + // force clear cached pref value + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + assert.isTrue(feed.loaded); + + feed._prefCache = {}; + setPref(CONFIG_PREF_NAME, { enabled: false }); + sandbox.stub(feed, "resetCache").returns(Promise.resolve()); + sandbox.stub(feed, "loadLayout").returns(Promise.resolve()); + await feed.onAction({ type: at.DISCOVERY_STREAM_CONFIG_CHANGE }); + + assert.notCalled(feed.loadLayout); + assert.calledOnce(feed.resetCache); + assert.isFalse(feed.loaded); + }); + }); + + describe("#onAction: UNINIT", () => { + it("should reset pref cache", async () => { + feed._prefCache = { cached: "value" }; + + await feed.onAction({ type: at.UNINIT }); + + assert.deepEqual(feed._prefCache, {}); + }); + }); + + describe("#onAction: PREF_CHANGED", () => { + it("should update state.DiscoveryStream.config when the pref changes", async () => { + setPref(CONFIG_PREF_NAME, { + enabled: true, + show_spocs: false, + layout_endpoint: "foo", + }); + + assert.deepEqual(feed.store.getState().DiscoveryStream.config, { + enabled: true, + show_spocs: false, + layout_endpoint: "foo", + }); + }); + it("should fire loadSpocs is showSponsored pref changes", async () => { + sandbox.stub(feed, "loadSpocs").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.loadSpocs); + }); + it("should fire onPrefChange when pocketConfig pref changes", async () => { + sandbox.stub(feed, "onPrefChange").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "pocketConfig", value: false }, + }); + + assert.calledOnce(feed.onPrefChange); + }); + it("should fire onCollectionsChanged when collections pref changes", async () => { + sandbox.stub(feed, "onCollectionsChanged").returns(Promise.resolve()); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.sponsored-collections.enabled" }, + }); + + assert.calledOnce(feed.onCollectionsChanged); + }); + it("should re enable stories when top stories is turned on", async () => { + sandbox.stub(feed, "refreshAll").returns(Promise.resolve()); + feed.loaded = true; + setPref(CONFIG_PREF_NAME, { + enabled: true, + }); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "feeds.section.topstories", value: true }, + }); + + assert.calledOnce(feed.refreshAll); + }); + }); + + describe("#onAction: SYSTEM_TICK", () => { + it("should not refresh if DiscoveryStream has not been loaded", async () => { + sandbox.stub(feed, "refreshAll").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(feed.refreshAll); + }); + + it("should not refresh if no caches are expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(false); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(feed.refreshAll); + }); + + it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.calledOnce(feed.refreshAll); + }); + + it("should refresh and not update open tabs if DiscoveryStream has been loaded at least once", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.SYSTEM_TICK }); + assert.calledWith(feed.refreshAll, { updateOpenTabs: false }); + }); + }); + + describe("#onCollectionsChanged", () => { + it("should call loadLayout when Pocket config changes", async () => { + sandbox.stub(feed, "loadLayout").callsFake(dispatch => dispatch("foo")); + sandbox.stub(feed.store, "dispatch"); + await feed.onCollectionsChanged(); + assert.calledOnce(feed.loadLayout); + assert.calledWith(feed.store.dispatch, ac.AlsoToPreloaded("foo")); + }); + }); + + describe("#onPrefChange", () => { + it("should call loadLayout when Pocket config changes", async () => { + sandbox.stub(feed, "loadLayout"); + feed._prefCache.config = { + enabled: true, + }; + await feed.onPrefChange(); + assert.calledOnce(feed.loadLayout); + }); + }); + + describe("#onAction: PREF_SHOW_SPONSORED", () => { + it("should call loadSpocs when preference changes", async () => { + sandbox.stub(feed, "loadSpocs").resolves(); + sandbox.stub(feed.store, "dispatch"); + + await feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored" }, + }); + + assert.calledOnce(feed.loadSpocs); + const [dispatchFn] = feed.loadSpocs.firstCall.args; + dispatchFn({}); + assert.calledWith(feed.store.dispatch, ac.BroadcastToContent({})); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_IDLE_DAILY", () => { + it("should trigger idle-daily observer", async () => { + sandbox.stub(global.Services.obs, "notifyObservers").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_IDLE_DAILY, + }); + assert.calledWith( + global.Services.obs.notifyObservers, + null, + "idle-daily" + ); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_SYNC_RS", () => { + it("should fire remote settings pollChanges", async () => { + sandbox.stub(global.RemoteSettings, "pollChanges").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_SYNC_RS, + }); + assert.calledOnce(global.RemoteSettings.pollChanges); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_SYSTEM_TICK", () => { + it("should refresh if DiscoveryStream has been loaded at least once and a cache has expired", async () => { + sandbox.stub(feed.cache, "set").resolves(); + setPref(CONFIG_PREF_NAME, { enabled: true }); + + await feed.onAction({ type: at.INIT }); + + sandbox.stub(feed, "checkIfAnyCacheExpired").resolves(true); + sandbox.stub(feed, "refreshAll").resolves(); + + await feed.onAction({ type: at.DISCOVERY_STREAM_DEV_SYSTEM_TICK }); + assert.calledOnce(feed.refreshAll); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_DEV_EXPIRE_CACHE", () => { + it("should fire resetCache", async () => { + sandbox.stub(feed, "resetContentCache").returns(); + await feed.onAction({ + type: at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE, + }); + assert.calledOnce(feed.resetContentCache); + }); + }); + + describe("#spocsCacheUpdateTime", () => { + it("should call setupSpocsCacheUpdateTime", () => { + const defaultCacheTime = 30 * 60 * 1000; + sandbox.spy(feed, "setupSpocsCacheUpdateTime"); + const cacheTime = feed.spocsCacheUpdateTime; + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + assert.equal(cacheTime, defaultCacheTime); + assert.calledOnce(feed.setupSpocsCacheUpdateTime); + }); + it("should return _spocsCacheUpdateTime", () => { + sandbox.spy(feed, "setupSpocsCacheUpdateTime"); + const testCacheTime = 123; + feed._spocsCacheUpdateTime = testCacheTime; + const cacheTime = feed.spocsCacheUpdateTime; + // Ensure _spocsCacheUpdateTime was not changed. + assert.equal(feed._spocsCacheUpdateTime, testCacheTime); + assert.equal(cacheTime, testCacheTime); + assert.notCalled(feed.setupSpocsCacheUpdateTime); + }); + }); + + describe("#setupSpocsCacheUpdateTime", () => { + it("should set _spocsCacheUpdateTime with default value", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with min", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 1, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with max", () => { + const defaultCacheTime = 30 * 60 * 1000; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 31, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, defaultCacheTime); + }); + it("should set _spocsCacheUpdateTime with spocsCacheTimeout", () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + spocsCacheTimeout: 20, + }, + }, + }, + }); + feed.setupSpocsCacheUpdateTime(); + assert.equal(feed._spocsCacheUpdateTime, 20 * 60 * 1000); + }); + }); + + describe("#isExpired", () => { + it("should throw if the key is not valid", () => { + assert.throws(() => { + feed.isExpired({}, "foo"); + }); + }); + it("should return false for layout on startup for content under 1 week", () => { + const layout = { lastUpdated: Date.now() }; + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isFalse(result); + }); + it("should return true for layout for isStartup=false after 30 mins", () => { + const layout = { lastUpdated: Date.now() }; + clock.tick(THIRTY_MINUTES + 1); + const result = feed.isExpired({ cachedData: { layout }, key: "layout" }); + + assert.isTrue(result); + }); + it("should return true for layout on startup for content over 1 week", () => { + const layout = { lastUpdated: Date.now() }; + clock.tick(ONE_WEEK + 1); + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isTrue(result); + }); + it("should return false for hardcoded layout on startup for content over 1 week", () => { + feed._prefCache.config = { + hardcoded_layout: true, + }; + const layout = { lastUpdated: Date.now() }; + clock.tick(ONE_WEEK + 1); + const result = feed.isExpired({ + cachedData: { layout }, + key: "layout", + isStartup: true, + }); + + assert.isFalse(result); + }); + }); + + describe("#checkIfAnyCacheExpired", () => { + let cache; + beforeEach(() => { + cache = { + layout: { lastUpdated: Date.now() }, + feeds: { "foo.com": { lastUpdated: Date.now() } }, + spocs: { lastUpdated: Date.now() }, + }; + Object.defineProperty(feed, "showSpocs", { get: () => true }); + sandbox.stub(feed.cache, "get").resolves(cache); + }); + + it("should return false if nothing in the cache is expired", async () => { + const result = await feed.checkIfAnyCacheExpired(); + assert.isFalse(result); + }); + + it("should return true if .layout is missing", async () => { + delete cache.layout; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if .layout is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.feeds["foo.com"].lastUpdate = Date.now(); + cache.spocs.lastUpdate = Date.now(); + + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + + it("should return true if .spocs is missing", async () => { + delete cache.spocs; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if .spocs is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.layout.lastUpdated = Date.now(); + cache.feeds["foo.com"].lastUpdate = Date.now(); + + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + + it("should return true if .feeds is missing", async () => { + delete cache.feeds; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if data for .feeds[url] is missing", async () => { + cache.feeds["foo.com"] = null; + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + it("should return true if data for .feeds[url] is expired", async () => { + clock.tick(THIRTY_MINUTES + 1); + // Update other caches we aren't testing + cache.layout.lastUpdated = Date.now(); + cache.spocs.lastUpdate = Date.now(); + assert.isTrue(await feed.checkIfAnyCacheExpired()); + }); + }); + + describe("#refreshAll", () => { + beforeEach(() => { + sandbox.stub(feed, "loadLayout").resolves(); + sandbox.stub(feed, "loadComponentFeeds").resolves(); + sandbox.stub(feed, "loadSpocs").resolves(); + sandbox.spy(feed.store, "dispatch"); + Object.defineProperty(feed, "showSpocs", { get: () => true }); + }); + + it("should call layout, component, spocs update and telemetry reporting functions", async () => { + await feed.refreshAll(); + + assert.calledOnce(feed.loadLayout); + assert.calledOnce(feed.loadComponentFeeds); + assert.calledOnce(feed.loadSpocs); + }); + it("should pass in dispatch wrapped with broadcast if options.updateOpenTabs is true", async () => { + await feed.refreshAll({ updateOpenTabs: true }); + [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => { + assert.calledOnce(fn); + const result = fn.firstCall.args[0]({ type: "FOO" }); + assert.isTrue(au.isBroadcastToContent(result)); + }); + }); + it("should pass in dispatch with regular actions if options.updateOpenTabs is false", async () => { + await feed.refreshAll({ updateOpenTabs: false }); + [feed.loadLayout, feed.loadComponentFeeds, feed.loadSpocs].forEach(fn => { + assert.calledOnce(fn); + const result = fn.firstCall.args[0]({ type: "FOO" }); + assert.deepEqual(result, { type: "FOO" }); + }); + }); + it("should set loaded to true if loadSpocs and loadComponentFeeds fails", async () => { + feed.loadComponentFeeds.rejects("loadComponentFeeds error"); + feed.loadSpocs.rejects("loadSpocs error"); + + await feed.enable(); + + assert.isTrue(feed.loaded); + }); + it("should call loadComponentFeeds and loadSpocs in Promise.all", async () => { + sandbox.stub(global.Promise, "all").resolves(); + + await feed.refreshAll(); + + assert.calledOnce(global.Promise.all); + const { args } = global.Promise.all.firstCall; + assert.equal(args[0].length, 2); + }); + describe("test startup cache behaviour", () => { + beforeEach(() => { + feed._maybeUpdateCachedData.restore(); + sandbox.stub(feed.cache, "set").resolves(); + }); + it("should refresh layout on startup if it was served from cache", async () => { + feed.loadLayout.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ layout: { lastUpdated: Date.now(), layout: {} } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} }); + clock.tick(THIRTY_MINUTES + 1); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the store + assert.calledTwice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_LAYOUT_UPDATE + ); + }); + it("should not refresh layout on startup if it is under THIRTY_MINUTES", async () => { + feed.loadLayout.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ layout: { lastUpdated: Date.now(), layout: {} } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ layout: {} }); + + await feed.refreshAll({ isStartup: true }); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should refresh spocs on startup if it was served from cache", async () => { + feed.loadSpocs.restore(); + sandbox.stub(feed, "getPlacements").returns([{ name: "spocs" }]); + sandbox + .stub(feed.cache, "get") + .resolves({ spocs: { lastUpdated: Date.now() } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves("data"); + clock.tick(THIRTY_MINUTES + 1); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the store + assert.calledTwice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_SPOCS_UPDATE + ); + }); + it("should not refresh spocs on startup if it is under THIRTY_MINUTES", async () => { + feed.loadSpocs.restore(); + sandbox + .stub(feed.cache, "get") + .resolves({ spocs: { lastUpdated: Date.now() } }); + sandbox.stub(feed, "fetchFromEndpoint").resolves("data"); + + await feed.refreshAll({ isStartup: true }); + + assert.notCalled(feed.fetchFromEndpoint); + }); + it("should refresh feeds on startup if it was served from cache", async () => { + feed.loadComponentFeeds.restore(); + + const fakeComponents = { components: [{ feed: { url: "foo.com" } }] }; + const fakeLayout = [fakeComponents]; + const fakeDiscoveryStream = { + DiscoveryStream: { + layout: fakeLayout, + }, + Prefs: { + values: { + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "cleanUpTopRecImpressionPref").callsFake(val => val); + + const fakeCache = { + feeds: { "foo.com": { lastUpdated: Date.now(), data: "data" } }, + }; + sandbox.stub(feed.cache, "get").resolves(fakeCache); + clock.tick(THIRTY_MINUTES + 1); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendations: "data", + settings: { + recsExpireTime: 1, + }, + }); + + await feed.refreshAll({ isStartup: true }); + + assert.calledOnce(feed.fetchFromEndpoint); + // Once from cache, once to update the feed, once to update that all feeds are done. + assert.calledThrice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + at.DISCOVERY_STREAM_FEEDS_UPDATE + ); + }); + }); + }); + + describe("#scoreFeeds", () => { + it("should score feeds and set cache, and dispatch", async () => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + const recsExpireTime = 5600; + const fakeImpressions = { + first: Date.now() - recsExpireTime * 1000, + third: Date.now(), + }; + sandbox.stub(feed, "readDataPref").returns(fakeImpressions); + const fakeFeeds = { + data: { + "https://foo.com": { + data: { + recommendations: [ + { + id: "first", + item_score: 0.7, + }, + { + id: "second", + item_score: 0.6, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "third", + item_score: 0.4, + }, + { + id: "fourth", + item_score: 0.6, + }, + { + id: "fifth", + item_score: 0.8, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + }, + }; + const feedsTestResult = { + "https://foo.com": { + data: { + recommendations: [ + { + id: "second", + item_score: 0.6, + score: 0.6, + }, + { + id: "first", + item_score: 0.7, + score: 0.7, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + "https://bar.com": { + data: { + recommendations: [ + { + id: "fifth", + item_score: 0.8, + score: 0.8, + }, + { + id: "fourth", + item_score: 0.6, + score: 0.6, + }, + { + id: "third", + item_score: 0.4, + score: 0.4, + }, + ], + settings: { + recsExpireTime, + }, + }, + }, + }; + + await feed.scoreFeeds(fakeFeeds); + + assert.calledWith(feed.cache.set, "feeds", feedsTestResult); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_FEED_UPDATE + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "https://foo.com", + feed: feedsTestResult["https://foo.com"], + }); + assert.equal( + feed.store.dispatch.secondCall.args[0].type, + at.DISCOVERY_STREAM_FEED_UPDATE + ); + assert.deepEqual(feed.store.dispatch.secondCall.args[0].data, { + url: "https://bar.com", + feed: feedsTestResult["https://bar.com"], + }); + }); + }); + + describe("#scoreSpocs", () => { + it("should score spocs and set cache, dispatch", async () => { + sandbox.stub(feed.cache, "set").resolves(); + sandbox.spy(feed.store, "dispatch"); + const fakeDiscoveryStream = { + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": false, + }, + }, + DiscoveryStream: { + spocs: { + placements: [ + { name: "placement1" }, + { name: "placement2" }, + { name: "placement3" }, + ], + }, + }, + }; + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + const fakeSpocs = { + lastUpdated: 1234, + data: { + placement1: { + items: [ + { + item_score: 0.6, + }, + { + item_score: 0.4, + }, + { + item_score: 0.8, + }, + ], + }, + placement2: { + items: [ + { + item_score: 0.6, + }, + { + item_score: 0.8, + }, + ], + }, + placement3: { items: [] }, + }, + }; + + await feed.scoreSpocs(fakeSpocs); + + const spocsTestResult = { + lastUpdated: 1234, + spocs: { + placement1: { + items: [ + { + score: 0.8, + item_score: 0.8, + }, + { + score: 0.6, + item_score: 0.6, + }, + { + score: 0.4, + item_score: 0.4, + }, + ], + }, + placement2: { + items: [ + { + score: 0.8, + item_score: 0.8, + }, + { + score: 0.6, + item_score: 0.6, + }, + ], + }, + placement3: { items: [] }, + }, + }; + assert.calledWith(feed.cache.set, "spocs", spocsTestResult); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.DISCOVERY_STREAM_SPOCS_UPDATE + ); + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data, + spocsTestResult + ); + }); + }); + + describe("#scoreContent", () => { + it("should call scoreFeeds and scoreSpocs if loaded", async () => { + const fakeDiscoveryStream = { + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + DiscoveryStream: { + feeds: { loaded: false }, + spocs: { loaded: false }, + }, + }; + + sandbox.stub(feed, "scoreFeeds").resolves(); + sandbox.stub(feed, "scoreSpocs").resolves(); + sandbox.stub(feed, "refreshContent").resolves(); + sandbox.stub(feed, "loadPersonalizationScoresCache").resolves(); + sandbox.stub(feed.store, "getState").returns(fakeDiscoveryStream); + sandbox.stub(feed, "_checkExpirationPerComponent").resolves({ + feeds: true, + spocs: true, + }); + + await feed.refreshAll(); + + assert.notCalled(feed.scoreFeeds); + assert.notCalled(feed.scoreSpocs); + + fakeDiscoveryStream.DiscoveryStream.feeds.loaded = true; + fakeDiscoveryStream.DiscoveryStream.spocs.loaded = true; + + await feed.refreshAll(); + + assert.calledOnce(feed.scoreFeeds); + assert.calledOnce(feed.scoreSpocs); + }); + }); + + describe("#loadPersonalizationScoresCache", () => { + it("should create a personalization provider from cached scores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + const fakeCache = { + personalization: { + scores: 123, + _timestamp: 456, + }, + }; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + + await feed.loadPersonalizationScoresCache(); + + assert.equal(feed.personalizationLastUpdated, 456); + }); + }); + + describe("#observe", () => { + it("should call updatePersonalizationScores on idle daily", async () => { + sandbox.stub(feed, "updatePersonalizationScores").returns(); + feed.observe(null, "idle-daily"); + assert.calledOnce(feed.updatePersonalizationScores); + }); + it("should call configReset on Pocket button pref change", async () => { + sandbox.stub(feed, "configReset").returns(); + feed.observe(null, "nsPref:changed", "extensions.pocket.enabled"); + assert.calledOnce(feed.configReset); + }); + }); + + describe("#updatePersonalizationScores", () => { + it("should update recommendationProvider on updatePersonalizationScores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + sandbox.stub(feed.recommendationProvider, "init").returns(); + + await feed.updatePersonalizationScores(); + + assert.deepEqual(feed.recommendationProvider.provider.getScores(), { + interestConfig: undefined, + interestVector: undefined, + }); + }); + it("should not update recommendationProvider on updatePersonalizationScores", async () => { + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "discoverystream.personalization.enabled": false, + }, + }, + }); + await feed.updatePersonalizationScores(); + + assert.isTrue(!feed.recommendationProvider.provider); + }); + }); + describe("#scoreItem", () => { + it("should call calculateItemRelevanceScore with recommendationProvider with initial score", async () => { + const item = { + item_score: 0.6, + }; + feed.store.getState = () => ({ + Prefs: { + values: { + pocketConfig: { + recsPersonalized: true, + spocsPersonalized: true, + }, + "discoverystream.personalization.enabled": true, + "feeds.section.topstories": true, + "feeds.system.topstories": true, + }, + }, + }); + feed.recommendationProvider.calculateItemRelevanceScore = sandbox + .stub() + .returns(); + const result = await feed.scoreItem(item, true); + assert.calledOnce( + feed.recommendationProvider.calculateItemRelevanceScore + ); + assert.equal(result.score, 0.6); + }); + it("should fallback to score 1 without an initial score", async () => { + const item = {}; + feed.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.spocs.personalized": true, + "discoverystream.recs.personalized": true, + "discoverystream.personalization.enabled": true, + }, + }, + }); + feed.recommendationProvider.calculateItemRelevanceScore = sandbox + .stub() + .returns(); + const result = await feed.scoreItem(item, true); + assert.equal(result.score, 1); + }); + }); + describe("new proxy feed", () => { + beforeEach(() => { + feed.store = createStore(combineReducers(reducers), { + Prefs: { + values: { + pocketConfig: { regionBffConfig: "DE" }, + }, + }, + }); + sandbox.stub(global.Region, "home").get(() => "DE"); + globals.set("NimbusFeatures", { + saveToPocket: { + getVariable: sandbox.stub(), + }, + }); + global.NimbusFeatures.saveToPocket.getVariable + .withArgs("bffApi") + .returns("bffApi"); + global.NimbusFeatures.saveToPocket.getVariable + .withArgs("oAuthConsumerKeyBff") + .returns("oAuthConsumerKeyBff"); + }); + it("should return true with isBff", async () => { + assert.isUndefined(feed._isBff); + assert.isTrue(feed.isBff); + assert.isTrue(feed._isBff); + }); + it("should update to new feed url", async () => { + await feed.loadLayout(feed.store.dispatch); + const { layout } = feed.store.getState().DiscoveryStream; + assert.equal( + layout[0].components[2].feed.url, + "https://bffApi/desktop/v1/recommendations?locale=$locale®ion=$region&count=30" + ); + }); + it("should fetch proper data from getComponentFeed", async () => { + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [] })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + data: [ + { + tileId: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + imageUrl: "imageUrl", + }, + ], + }); + + const feedData = await feed.getComponentFeed("url"); + assert.deepEqual(feedData, { + lastUpdated: 0, + data: { + settings: {}, + recommendations: [ + { + id: 1234, + url: "url", + title: "title", + excerpt: "excerpt", + publisher: "publisher", + raw_image_src: "imageUrl", + }, + ], + status: "success", + }, + }); + assert.equal(feed.fetchFromEndpoint.firstCall.args[0], "url"); + assert.equal(feed.fetchFromEndpoint.firstCall.args[1].method, "GET"); + assert.equal( + feed.fetchFromEndpoint.firstCall.args[1].headers.get("consumer_key"), + "oAuthConsumerKeyBff" + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/DownloadsManager.test.js b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js new file mode 100644 index 0000000000..0dfdff548b --- /dev/null +++ b/browser/components/newtab/test/unit/lib/DownloadsManager.test.js @@ -0,0 +1,373 @@ +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { DownloadsManager } from "lib/DownloadsManager.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("Downloads Manager", () => { + let downloadsManager; + let globals; + const DOWNLOAD_URL = "https://site.com/download.mov"; + + beforeEach(() => { + globals = new GlobalOverrider(); + global.Cc["@mozilla.org/timer;1"] = { + createInstance() { + return { + initWithCallback: sinon.stub().callsFake(callback => callback()), + cancel: sinon.spy(), + }; + }, + }; + + globals.set("DownloadsCommon", { + getData: sinon.stub().returns({ + addView: sinon.stub(), + removeView: sinon.stub(), + }), + copyDownloadLink: sinon.stub(), + deleteDownload: sinon.stub().returns(Promise.resolve()), + openDownload: sinon.stub(), + showDownloadedFile: sinon.stub(), + }); + + downloadsManager = new DownloadsManager(); + downloadsManager.init({ dispatch() {} }); + downloadsManager.onDownloadAdded({ + source: { url: DOWNLOAD_URL }, + endTime: Date.now(), + target: { path: "/path/to/download.mov", exists: true }, + succeeded: true, + refresh: async () => {}, + }); + assert.ok(downloadsManager._downloadItems.has(DOWNLOAD_URL)); + + globals.set("NewTabUtils", { blockedLinks: { isBlocked() {} } }); + }); + afterEach(() => { + downloadsManager._downloadItems.clear(); + globals.restore(); + }); + describe("#init", () => { + it("should add a DownloadsCommon view on init", () => { + downloadsManager.init({ dispatch() {} }); + assert.calledTwice(global.DownloadsCommon.getData().addView); + }); + }); + describe("#onAction", () => { + it("should copy the file on COPY_DOWNLOAD_LINK", () => { + downloadsManager.onAction({ + type: at.COPY_DOWNLOAD_LINK, + data: { url: DOWNLOAD_URL }, + }); + assert.calledOnce(global.DownloadsCommon.copyDownloadLink); + }); + it("should remove the file on REMOVE_DOWNLOAD_FILE", () => { + downloadsManager.onAction({ + type: at.REMOVE_DOWNLOAD_FILE, + data: { url: DOWNLOAD_URL }, + }); + assert.calledOnce(global.DownloadsCommon.deleteDownload); + }); + it("should show the file on SHOW_DOWNLOAD_FILE", () => { + downloadsManager.onAction({ + type: at.SHOW_DOWNLOAD_FILE, + data: { url: DOWNLOAD_URL }, + }); + assert.calledOnce(global.DownloadsCommon.showDownloadedFile); + }); + it("should open the file on OPEN_DOWNLOAD_FILE if the type is download", () => { + downloadsManager.onAction({ + type: at.OPEN_DOWNLOAD_FILE, + data: { url: DOWNLOAD_URL, type: "download" }, + _target: { browser: {} }, + }); + assert.calledOnce(global.DownloadsCommon.openDownload); + }); + it("should copy the file on UNINIT", () => { + // DownloadsManager._downloadData needs to exist first + downloadsManager.onAction({ type: at.UNINIT }); + assert.calledOnce(global.DownloadsCommon.getData().removeView); + }); + it("should not execute a download command if we do not have the correct url", () => { + downloadsManager.onAction({ + type: at.SHOW_DOWNLOAD_FILE, + data: { url: "unknown_url" }, + }); + assert.notCalled(global.DownloadsCommon.showDownloadedFile); + }); + }); + describe("#onDownloadAdded", () => { + let newDownload; + beforeEach(() => { + downloadsManager._downloadItems.clear(); + newDownload = { + source: { url: "https://site.com/newDownload.mov" }, + endTime: Date.now(), + target: { path: "/path/to/newDownload.mov", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + }); + afterEach(() => { + downloadsManager._downloadItems.clear(); + }); + it("should add a download on onDownloadAdded", () => { + downloadsManager.onDownloadAdded(newDownload); + assert.ok( + downloadsManager._downloadItems.has("https://site.com/newDownload.mov") + ); + }); + it("should not add a download if it already exists", () => { + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(newDownload); + const results = downloadsManager._downloadItems; + assert.equal(results.size, 1); + }); + it("should not return any downloads if no threshold is provided", async () => { + downloadsManager.onDownloadAdded(newDownload); + const results = await downloadsManager.getDownloads(null, {}); + assert.equal(results.length, 0); + }); + it("should stop at numItems when it found one it's looking for", async () => { + const aDownload = { + source: { url: "https://site.com/aDownload.pdf" }, + endTime: Date.now(), + target: { path: "/path/to/aDownload.pdf", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(aDownload); + downloadsManager.onDownloadAdded(newDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 1, + onlySucceeded: true, + onlyExists: true, + }); + assert.equal(results.length, 1); + assert.equal(results[0].url, aDownload.source.url); + }); + it("should get all the downloads younger than the threshold provided", async () => { + const oldDownload = { + source: { url: "https://site.com/oldDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/oldDownload.pdf", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + // Add an old download (older than 36 hours in this case) + downloadsManager.onDownloadAdded(oldDownload); + downloadsManager.onDownloadAdded(newDownload); + const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000; + const results = await downloadsManager.getDownloads( + RECENT_DOWNLOAD_THRESHOLD, + { numItems: 5, onlySucceeded: true, onlyExists: true } + ); + assert.equal(results.length, 1); + assert.equal(results[0].url, newDownload.source.url); + }); + it("should dispatch DOWNLOAD_CHANGED when adding a download", () => { + downloadsManager._store.dispatch = sinon.spy(); + downloadsManager._downloadTimer = null; // Nuke the timer + downloadsManager.onDownloadAdded(newDownload); + assert.calledOnce(downloadsManager._store.dispatch); + }); + it("should refresh the downloads if onlyExists is true", async () => { + const aDownload = { + source: { url: "https://site.com/aDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/aDownload.pdf", exists: true }, + succeeded: true, + refresh: () => {}, + }; + sinon.stub(aDownload, "refresh").returns(Promise.resolve()); + downloadsManager.onDownloadAdded(aDownload); + await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + onlyExists: true, + }); + assert.calledOnce(aDownload.refresh); + }); + it("should not refresh the downloads if onlyExists is false (by default)", async () => { + const aDownload = { + source: { url: "https://site.com/aDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/aDownload.pdf", exists: true }, + succeeded: true, + refresh: () => {}, + }; + sinon.stub(aDownload, "refresh").returns(Promise.resolve()); + downloadsManager.onDownloadAdded(aDownload); + await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + }); + assert.notCalled(aDownload.refresh); + }); + it("should only return downloads that exist if specified", async () => { + const nonExistantDownload = { + source: { url: "https://site.com/nonExistantDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/nonExistantDownload.pdf", exists: false }, + succeeded: true, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(nonExistantDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + onlyExists: true, + }); + assert.equal(results.length, 1); + assert.equal(results[0].url, newDownload.source.url); + }); + it("should return all downloads that either exist or don't exist if not specified", async () => { + const nonExistantDownload = { + source: { url: "https://site.com/nonExistantDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/nonExistantDownload.pdf", exists: false }, + succeeded: true, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(nonExistantDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + }); + assert.equal(results.length, 2); + assert.equal(results[0].url, newDownload.source.url); + assert.equal(results[1].url, nonExistantDownload.source.url); + }); + it("should return only unblocked downloads", async () => { + const nonExistantDownload = { + source: { url: "https://site.com/nonExistantDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/nonExistantDownload.pdf", exists: false }, + succeeded: true, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(nonExistantDownload); + globals.set("NewTabUtils", { + blockedLinks: { + isBlocked: item => item.url === nonExistantDownload.source.url, + }, + }); + + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + }); + + assert.equal(results.length, 1); + assert.propertyVal(results[0], "url", newDownload.source.url); + }); + it("should only return downloads that were successful if specified", async () => { + const nonSuccessfulDownload = { + source: { url: "https://site.com/nonSuccessfulDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/nonSuccessfulDownload.pdf", exists: false }, + succeeded: false, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(nonSuccessfulDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + }); + assert.equal(results.length, 1); + assert.equal(results[0].url, newDownload.source.url); + }); + it("should return all downloads that were either successful or not if not specified", async () => { + const nonExistantDownload = { + source: { url: "https://site.com/nonExistantDownload.pdf" }, + endTime: Date.now() - 40 * 60 * 60 * 1000, + target: { path: "/path/to/nonExistantDownload.pdf", exists: true }, + succeeded: false, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + downloadsManager.onDownloadAdded(nonExistantDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + }); + assert.equal(results.length, 2); + assert.equal(results[0].url, newDownload.source.url); + assert.equal(results[1].url, nonExistantDownload.source.url); + }); + it("should sort the downloads by recency", async () => { + const olderDownload1 = { + source: { url: "https://site.com/oldDownload1.pdf" }, + endTime: Date.now() - 2 * 60 * 60 * 1000, // 2 hours ago + target: { path: "/path/to/oldDownload1.pdf", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + const olderDownload2 = { + source: { url: "https://site.com/oldDownload2.pdf" }, + endTime: Date.now() - 60 * 60 * 1000, // 1 hour ago + target: { path: "/path/to/oldDownload2.pdf", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + // Add some older downloads and check that they are in order + downloadsManager.onDownloadAdded(olderDownload1); + downloadsManager.onDownloadAdded(olderDownload2); + downloadsManager.onDownloadAdded(newDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + onlyExists: true, + }); + assert.equal(results.length, 3); + assert.equal(results[0].url, newDownload.source.url); + assert.equal(results[1].url, olderDownload2.source.url); + assert.equal(results[2].url, olderDownload1.source.url); + }); + it("should format the description properly if there is no file type", async () => { + newDownload.target.path = null; + downloadsManager.onDownloadAdded(newDownload); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + onlySucceeded: true, + onlyExists: true, + }); + assert.equal(results.length, 1); + assert.equal(results[0].description, "1.5 MB"); // see unit-entry.js to see where this comes from + }); + }); + describe("#onDownloadRemoved", () => { + let newDownload; + beforeEach(() => { + downloadsManager._downloadItems.clear(); + newDownload = { + source: { url: "https://site.com/removeMe.mov" }, + endTime: Date.now(), + target: { path: "/path/to/removeMe.mov", exists: true }, + succeeded: true, + refresh: async () => {}, + }; + downloadsManager.onDownloadAdded(newDownload); + }); + it("should remove a download if it exists on onDownloadRemoved", async () => { + downloadsManager.onDownloadRemoved({ + source: { url: "https://site.com/removeMe.mov" }, + }); + const results = await downloadsManager.getDownloads(Infinity, { + numItems: 5, + }); + assert.deepEqual(results, []); + }); + it("should dispatch DOWNLOAD_CHANGED when removing a download", () => { + downloadsManager._store.dispatch = sinon.spy(); + downloadsManager.onDownloadRemoved({ + source: { url: "https://site.com/removeMe.mov" }, + }); + assert.calledOnce(downloadsManager._store.dispatch); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/FaviconFeed.test.js b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js new file mode 100644 index 0000000000..6476e2a3be --- /dev/null +++ b/browser/components/newtab/test/unit/lib/FaviconFeed.test.js @@ -0,0 +1,233 @@ +"use strict"; +import { FaviconFeed, fetchIconFromRedirects } from "lib/FaviconFeed.jsm"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +const FAKE_ENDPOINT = "https://foo.com/"; + +describe("FaviconFeed", () => { + let feed; + let globals; + let sandbox; + let clock; + let siteIconsPref; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + globals.set("PlacesUtils", { + favicons: { + setAndFetchFaviconForPage: sandbox.spy(), + getFaviconDataForPage: () => Promise.resolve(null), + FAVICON_LOAD_NON_PRIVATE: 1, + }, + history: { + TRANSITIONS: { + REDIRECT_TEMPORARY: 1, + REDIRECT_PERMANENT: 2, + }, + }, + }); + globals.set("NewTabUtils", { + activityStreamProvider: { executePlacesQuery: () => Promise.resolve([]) }, + }); + siteIconsPref = true; + sandbox + .stub(global.Services.prefs, "getBoolPref") + .withArgs("browser.chrome.site_icons") + .callsFake(() => siteIconsPref); + + feed = new FaviconFeed(); + feed.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { values: { "tippyTop.service.endpoint": FAKE_ENDPOINT } }, + }, + }; + }); + afterEach(() => { + clock.restore(); + globals.restore(); + }); + + it("should create a FaviconFeed", () => { + assert.instanceOf(feed, FaviconFeed); + }); + + describe("#fetchIcon", () => { + let domain; + let url; + beforeEach(() => { + domain = "mozilla.org"; + url = `https://${domain}/`; + feed.getSite = sandbox + .stub() + .returns(Promise.resolve({ domain, image_url: `${url}/icon.png` })); + feed._queryForRedirects.clear(); + }); + + it("should setAndFetchFaviconForPage if the url is in the TippyTop data", async () => { + await feed.fetchIcon(url); + + assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + assert.calledWith( + global.PlacesUtils.favicons.setAndFetchFaviconForPage, + sinon.match({ spec: url }), + { ref: "tippytop", spec: `${url}/icon.png` }, + false, + global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + undefined + ); + }); + it("should NOT setAndFetchFaviconForPage if site_icons pref is false", async () => { + siteIconsPref = false; + + await feed.fetchIcon(url); + + assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + }); + it("should NOT setAndFetchFaviconForPage if the url is NOT in the TippyTop data", async () => { + feed.getSite = sandbox.stub().returns(Promise.resolve(null)); + await feed.fetchIcon("https://example.com"); + + assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + }); + it("should issue a fetchIconFromRedirects if the url is NOT in the TippyTop data", async () => { + feed.getSite = sandbox.stub().returns(Promise.resolve(null)); + sandbox.spy(global.Services.tm, "idleDispatchToMainThread"); + + await feed.fetchIcon("https://example.com"); + + assert.calledOnce(global.Services.tm.idleDispatchToMainThread); + }); + it("should only issue fetchIconFromRedirects once on the same url", async () => { + feed.getSite = sandbox.stub().returns(Promise.resolve(null)); + sandbox.spy(global.Services.tm, "idleDispatchToMainThread"); + + await feed.fetchIcon("https://example.com"); + await feed.fetchIcon("https://example.com"); + + assert.calledOnce(global.Services.tm.idleDispatchToMainThread); + }); + it("should issue fetchIconFromRedirects twice on two different urls", async () => { + feed.getSite = sandbox.stub().returns(Promise.resolve(null)); + sandbox.spy(global.Services.tm, "idleDispatchToMainThread"); + + await feed.fetchIcon("https://example.com"); + await feed.fetchIcon("https://another.example.com"); + + assert.calledTwice(global.Services.tm.idleDispatchToMainThread); + }); + }); + + describe("#getSite", () => { + it("should return site data if RemoteSettings has an entry for the domain", async () => { + const get = () => + Promise.resolve([{ domain: "example.com", image_url: "foo.img" }]); + feed._tippyTop = { get }; + const site = await feed.getSite("example.com"); + assert.equal(site.domain, "example.com"); + }); + it("should return null if RemoteSettings doesn't have an entry for the domain", async () => { + const get = () => Promise.resolve([]); + feed._tippyTop = { get }; + const site = await feed.getSite("example.com"); + assert.isNull(site); + }); + it("should lazy init _tippyTop", async () => { + assert.isUndefined(feed._tippyTop); + await feed.getSite("example.com"); + assert.ok(feed._tippyTop); + }); + }); + + describe("#onAction", () => { + it("should fetchIcon on RICH_ICON_MISSING", async () => { + feed.fetchIcon = sinon.spy(); + const url = "https://mozilla.org"; + feed.onAction({ type: at.RICH_ICON_MISSING, data: { url } }); + assert.calledOnce(feed.fetchIcon); + assert.calledWith(feed.fetchIcon, url); + }); + }); + + describe("#fetchIconFromRedirects", () => { + let domain; + let url; + let iconUrl; + + beforeEach(() => { + domain = "mozilla.org"; + url = `https://${domain}/`; + iconUrl = `${url}/icon.png`; + }); + it("should setAndFetchFaviconForPage if the url was redirected with a icon", async () => { + sandbox + .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery") + .resolves([ + { visit_id: 1, url: domain }, + { visit_id: 2, url }, + ]); + sandbox + .stub(global.PlacesUtils.favicons, "getFaviconDataForPage") + .callsArgWith(1, { spec: iconUrl }, 0, null, null, 96); + + await fetchIconFromRedirects(domain); + + assert.calledOnce(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + assert.calledWith( + global.PlacesUtils.favicons.setAndFetchFaviconForPage, + sinon.match({ spec: domain }), + { spec: iconUrl }, + false, + global.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + undefined + ); + }); + it("should NOT setAndFetchFaviconForPage if the url doesn't have any redirect", async () => { + sandbox + .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery") + .resolves([]); + + await fetchIconFromRedirects(domain); + + assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + }); + it("should NOT setAndFetchFaviconForPage if the original url doesn't have a icon", async () => { + sandbox + .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery") + .resolves([ + { visit_id: 1, url: domain }, + { visit_id: 2, url }, + ]); + sandbox + .stub(global.PlacesUtils.favicons, "getFaviconDataForPage") + .callsArgWith(1, null, null, null, null, null); + + await fetchIconFromRedirects(domain); + + assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + }); + it("should NOT setAndFetchFaviconForPage if the original url doesn't have a rich icon", async () => { + sandbox + .stub(global.NewTabUtils.activityStreamProvider, "executePlacesQuery") + .resolves([ + { visit_id: 1, url: domain }, + { visit_id: 2, url }, + ]); + sandbox + .stub(global.PlacesUtils.favicons, "getFaviconDataForPage") + .callsArgWith(1, { spec: iconUrl }, 0, null, null, 16); + + await fetchIconFromRedirects(domain); + + assert.notCalled(global.PlacesUtils.favicons.setAndFetchFaviconForPage); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/FilterAdult.test.js b/browser/components/newtab/test/unit/lib/FilterAdult.test.js new file mode 100644 index 0000000000..e5d15a3fb0 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/FilterAdult.test.js @@ -0,0 +1,112 @@ +import { FilterAdult } from "lib/FilterAdult.jsm"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("FilterAdult", () => { + let hashStub; + let hashValue; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + hashStub = { + finish: sinon.stub().callsFake(() => hashValue), + init: sinon.stub(), + update: sinon.stub(), + }; + globals.set("Cc", { + "@mozilla.org/security/hash;1": { + createInstance() { + return hashStub; + }, + }, + }); + globals.set("gFilterAdultEnabled", true); + }); + + afterEach(() => { + hashValue = ""; + globals.restore(); + }); + + describe("filter", () => { + it("should default to include on unexpected urls", () => { + const empty = {}; + + const result = FilterAdult.filter([empty]); + + assert.equal(result.length, 1); + assert.equal(result[0], empty); + }); + it("should not filter out non-adult urls", () => { + const link = { url: "https://mozilla.org/" }; + + const result = FilterAdult.filter([link]); + + assert.equal(result.length, 1); + assert.equal(result[0], link); + }); + it("should filter out adult urls", () => { + // Use a hash value that is in the adult set + hashValue = "+/UCpAhZhz368iGioEO8aQ=="; + const link = { url: "https://some-adult-site/" }; + + const result = FilterAdult.filter([link]); + + assert.equal(result.length, 0); + }); + it("should not filter out adult urls if the preference is turned off", () => { + // Use a hash value that is in the adult set + hashValue = "+/UCpAhZhz368iGioEO8aQ=="; + globals.set("gFilterAdultEnabled", false); + const link = { url: "https://some-adult-site/" }; + + const result = FilterAdult.filter([link]); + + assert.equal(result.length, 1); + assert.equal(result[0], link); + }); + }); + + describe("isAdultUrl", () => { + it("should default to false on unexpected urls", () => { + const result = FilterAdult.isAdultUrl(""); + + assert.equal(result, false); + }); + it("should return false for non-adult urls", () => { + const result = FilterAdult.isAdultUrl("https://mozilla.org/"); + + assert.equal(result, false); + }); + it("should return true for adult urls", () => { + // Use a hash value that is in the adult set + hashValue = "+/UCpAhZhz368iGioEO8aQ=="; + const result = FilterAdult.isAdultUrl("https://some-adult-site/"); + + assert.equal(result, true); + }); + it("should return false for adult urls when the preference is turned off", () => { + // Use a hash value that is in the adult set + hashValue = "+/UCpAhZhz368iGioEO8aQ=="; + globals.set("gFilterAdultEnabled", false); + const result = FilterAdult.isAdultUrl("https://some-adult-site/"); + + assert.equal(result, false); + }); + + describe("test functions", () => { + it("should add and remove a filter in the adult list", () => { + // Use a hash value that is in the adult set + FilterAdult.addDomainToList("https://some-adult-site/"); + let result = FilterAdult.isAdultUrl("https://some-adult-site/"); + + assert.equal(result, true); + + FilterAdult.removeDomainFromList("https://some-adult-site/"); + result = FilterAdult.isAdultUrl("https://some-adult-site/"); + + assert.equal(result, false); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js new file mode 100644 index 0000000000..f0cd2450b7 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/HighlightsFeed.test.js @@ -0,0 +1,822 @@ +"use strict"; + +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { Dedupe } from "common/Dedupe.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import injector from "inject!lib/HighlightsFeed.jsm"; +import { Screenshots } from "lib/Screenshots.jsm"; +import { LinksCache } from "lib/LinksCache.sys.mjs"; + +const FAKE_LINKS = new Array(20) + .fill(null) + .map((v, i) => ({ url: `http://www.site${i}.com` })); +const FAKE_IMAGE = "data123"; + +describe("Highlights Feed", () => { + let HighlightsFeed; + let SECTION_ID; + let SYNC_BOOKMARKS_FINISHED_EVENT; + let BOOKMARKS_RESTORE_SUCCESS_EVENT; + let BOOKMARKS_RESTORE_FAILED_EVENT; + let feed; + let globals; + let sandbox; + let links; + let fakeScreenshot; + let fakeNewTabUtils; + let filterAdultStub; + let sectionsManagerStub; + let downloadsManagerStub; + let shortURLStub; + let fakePageThumbs; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + fakeNewTabUtils = { + activityStreamLinks: { + getHighlights: sandbox.spy(() => Promise.resolve(links)), + deletePocketEntry: sandbox.spy(() => Promise.resolve({})), + archivePocketEntry: sandbox.spy(() => Promise.resolve({})), + }, + activityStreamProvider: { + _processHighlights: sandbox.spy(l => l.slice(0, 1)), + }, + }; + sectionsManagerStub = { + onceInitialized: sinon.stub().callsFake(callback => callback()), + enableSection: sinon.spy(), + disableSection: sinon.spy(), + updateSection: sinon.spy(), + updateSectionCard: sinon.spy(), + sections: new Map([["highlights", { id: "highlights" }]]), + }; + downloadsManagerStub = sinon.stub().returns({ + getDownloads: () => [{ url: "https://site.com/download" }], + onAction: sinon.spy(), + init: sinon.spy(), + }); + fakeScreenshot = { + getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_IMAGE)), + maybeCacheScreenshot: Screenshots.maybeCacheScreenshot, + _shouldGetScreenshots: sinon.stub().returns(true), + }; + filterAdultStub = { + filter: sinon.stub().returnsArg(0), + }; + shortURLStub = sinon + .stub() + .callsFake(site => site.url.match(/\/([^/]+)/)[1]); + fakePageThumbs = { + addExpirationFilter: sinon.stub(), + removeExpirationFilter: sinon.stub(), + }; + + globals.set({ + NewTabUtils: fakeNewTabUtils, + PageThumbs: fakePageThumbs, + gFilterAdultEnabled: false, + LinksCache, + DownloadsManager: downloadsManagerStub, + FilterAdult: filterAdultStub, + Screenshots: fakeScreenshot, + }); + ({ + HighlightsFeed, + SECTION_ID, + SYNC_BOOKMARKS_FINISHED_EVENT, + BOOKMARKS_RESTORE_SUCCESS_EVENT, + BOOKMARKS_RESTORE_FAILED_EVENT, + } = injector({ + "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub }, + "lib/ShortURL.jsm": { shortURL: shortURLStub }, + "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub }, + "lib/Screenshots.jsm": { Screenshots: fakeScreenshot }, + "common/Dedupe.jsm": { Dedupe }, + "lib/DownloadsManager.jsm": { DownloadsManager: downloadsManagerStub }, + })); + sandbox.spy(global.Services.obs, "addObserver"); + sandbox.spy(global.Services.obs, "removeObserver"); + feed = new HighlightsFeed(); + feed.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { + values: { + "section.highlights.includePocket": false, + "section.highlights.includeDownloads": false, + }, + }, + TopSites: { + initialized: true, + rows: Array(12) + .fill(null) + .map((v, i) => ({ url: `http://www.topsite${i}.com` })), + }, + Sections: [{ id: "highlights", initialized: false }], + }, + subscribe: sinon.stub().callsFake(cb => { + cb(); + return () => {}; + }), + }; + links = FAKE_LINKS; + }); + afterEach(() => { + globals.restore(); + }); + + describe("#init", () => { + it("should create a HighlightsFeed", () => { + assert.instanceOf(feed, HighlightsFeed); + }); + it("should register a expiration filter", () => { + assert.calledOnce(fakePageThumbs.addExpirationFilter); + }); + it("should add the sync observer", () => { + feed.onAction({ type: at.INIT }); + assert.calledWith( + global.Services.obs.addObserver, + feed, + SYNC_BOOKMARKS_FINISHED_EVENT + ); + assert.calledWith( + global.Services.obs.addObserver, + feed, + BOOKMARKS_RESTORE_SUCCESS_EVENT + ); + assert.calledWith( + global.Services.obs.addObserver, + feed, + BOOKMARKS_RESTORE_FAILED_EVENT + ); + }); + it("should call SectionsManager.onceInitialized on INIT", () => { + feed.onAction({ type: at.INIT }); + assert.calledOnce(sectionsManagerStub.onceInitialized); + }); + it("should enable its section", () => { + feed.onAction({ type: at.INIT }); + assert.calledOnce(sectionsManagerStub.enableSection); + assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID); + }); + it("should fetch highlights on postInit", () => { + feed.fetchHighlights = sinon.spy(); + feed.postInit(); + assert.calledOnce(feed.fetchHighlights); + }); + it("should hook up the store for the DownloadsManager", () => { + feed.onAction({ type: at.INIT }); + assert.calledOnce(feed.downloadsManager.init); + }); + }); + describe("#observe", () => { + beforeEach(() => { + feed.fetchHighlights = sinon.spy(); + }); + it("should fetch higlights when we are done a sync for bookmarks", () => { + feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "bookmarks"); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should fetch highlights after a successful import", () => { + feed.observe(null, BOOKMARKS_RESTORE_SUCCESS_EVENT, "html"); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should fetch highlights after a failed import", () => { + feed.observe(null, BOOKMARKS_RESTORE_FAILED_EVENT, "json"); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should not fetch higlights when we are doing a sync for something that is not bookmarks", () => { + feed.observe(null, SYNC_BOOKMARKS_FINISHED_EVENT, "tabs"); + assert.notCalled(feed.fetchHighlights); + }); + it("should not fetch higlights for other events", () => { + feed.observe(null, "someotherevent", "bookmarks"); + assert.notCalled(feed.fetchHighlights); + }); + }); + describe("#filterForThumbnailExpiration", () => { + it("should pass rows.urls to the callback provided", () => { + const rows = [{ url: "foo.com" }, { url: "bar.com" }]; + feed.store.state.Sections = [ + { id: "highlights", rows, initialized: true }, + ]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + assert.calledOnce(stub); + assert.calledWithExactly( + stub, + rows.map(r => r.url) + ); + }); + it("should include preview_image_url (if present) in the callback results", () => { + const rows = [ + { url: "foo.com" }, + { url: "bar.com", preview_image_url: "bar.jpg" }, + ]; + feed.store.state.Sections = [ + { id: "highlights", rows, initialized: true }, + ]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, ["foo.com", "bar.com", "bar.jpg"]); + }); + it("should pass an empty array if not initialized", () => { + const rows = [{ url: "foo.com" }, { url: "bar.com" }]; + feed.store.state.Sections = [{ rows, initialized: false }]; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, []); + }); + }); + describe("#fetchHighlights", () => { + const fetchHighlights = async options => { + await feed.fetchHighlights(options); + return sectionsManagerStub.updateSection.firstCall.args[1].rows; + }; + it("should return early if TopSites are not initialised", async () => { + sandbox.spy(feed.linksCache, "request"); + feed.store.state.TopSites.initialized = false; + feed.store.state.Prefs.values["feeds.topsites"] = true; + feed.store.state.Prefs.values["feeds.system.topsites"] = true; + + // Initially TopSites is uninitialised and fetchHighlights should return. + await feed.fetchHighlights(); + + assert.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights); + assert.notCalled(feed.linksCache.request); + }); + it("should return early if Sections are not initialised", async () => { + sandbox.spy(feed.linksCache, "request"); + feed.store.state.TopSites.initialized = true; + feed.store.state.Prefs.values["feeds.topsites"] = true; + feed.store.state.Prefs.values["feeds.system.topsites"] = true; + feed.store.state.Sections = []; + + await feed.fetchHighlights(); + + assert.notCalled(fakeNewTabUtils.activityStreamLinks.getHighlights); + assert.notCalled(feed.linksCache.request); + }); + it("should fetch Highlights if TopSites are initialised", async () => { + sandbox.spy(feed.linksCache, "request"); + // fetchHighlights should continue + feed.store.state.TopSites.initialized = true; + + await feed.fetchHighlights(); + + assert.calledOnce(feed.linksCache.request); + assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights); + }); + it("should chronologically order highlight data types", async () => { + links = [ + { + url: "https://site0.com", + type: "bookmark", + bookmarkGuid: "1234", + date_added: Date.now() - 80, + }, // 3rd newest + { + url: "https://site1.com", + type: "history", + bookmarkGuid: "1234", + date_added: Date.now() - 60, + }, // append at the end + { + url: "https://site2.com", + type: "history", + date_added: Date.now() - 160, + }, // append at the end + { + url: "https://site3.com", + type: "history", + date_added: Date.now() - 60, + }, // append at the end + { url: "https://site4.com", type: "pocket", date_added: Date.now() }, // newest highlight + { + url: "https://site5.com", + type: "pocket", + date_added: Date.now() - 100, + }, // 4th newest + { + url: "https://site6.com", + type: "bookmark", + bookmarkGuid: "1234", + date_added: Date.now() - 40, + }, // 2nd newest + ]; + const expectedChronological = [4, 6, 0, 5]; + const expectedHistory = [1, 2, 3]; + + let highlights = await fetchHighlights(); + + [...expectedChronological, ...expectedHistory].forEach((link, index) => { + assert.propertyVal( + highlights[index], + "url", + links[link].url, + `highlight[${index}] should be link[${link}]` + ); + }); + }); + it("should fetch Highlights if TopSites are not enabled", async () => { + sandbox.spy(feed.linksCache, "request"); + feed.store.state.Prefs.values["feeds.system.topsites"] = false; + + await feed.fetchHighlights(); + + assert.calledOnce(feed.linksCache.request); + assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights); + }); + it("should fetch Highlights if TopSites are not shown on NTP", async () => { + sandbox.spy(feed.linksCache, "request"); + feed.store.state.Prefs.values["feeds.topsites"] = false; + + await feed.fetchHighlights(); + + assert.calledOnce(feed.linksCache.request); + assert.calledOnce(fakeNewTabUtils.activityStreamLinks.getHighlights); + }); + it("should add hostname and hasImage to each link", async () => { + links = [{ url: "https://mozilla.org" }]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights[0].hostname, "mozilla.org"); + assert.equal(highlights[0].hasImage, true); + }); + it("should add an existing image if it exists to the link without calling fetchImage", async () => { + links = [{ url: "https://mozilla.org", image: FAKE_IMAGE }]; + sinon.spy(feed, "fetchImage"); + + const highlights = await fetchHighlights(); + + assert.equal(highlights[0].image, FAKE_IMAGE); + assert.notCalled(feed.fetchImage); + }); + it("should call fetchImage with the correct arguments for new links", async () => { + links = [ + { + url: "https://mozilla.org", + preview_image_url: "https://mozilla.org/preview.jog", + }, + ]; + sinon.spy(feed, "fetchImage"); + + await feed.fetchHighlights(); + + assert.calledOnce(feed.fetchImage); + const [arg] = feed.fetchImage.firstCall.args; + assert.propertyVal(arg, "url", links[0].url); + assert.propertyVal(arg, "preview_image_url", links[0].preview_image_url); + }); + it("should not include any links already in Top Sites", async () => { + links = [ + { url: "https://mozilla.org" }, + { url: "http://www.topsite0.com" }, + { url: "http://www.topsite1.com" }, + { url: "http://www.topsite2.com" }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights.length, 1); + assert.equal(highlights[0].url, links[0].url); + }); + it("should include bookmark but not history already in Top Sites", async () => { + links = [ + { url: "http://www.topsite0.com", type: "bookmark" }, + { url: "http://www.topsite1.com", type: "history" }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights.length, 1); + assert.equal(highlights[0].url, links[0].url); + }); + it("should not include history of same hostname as a bookmark", async () => { + links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/history", type: "history" }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights.length, 1); + assert.equal(highlights[0].url, links[0].url); + }); + it("should take the first history of a hostname", async () => { + links = [ + { url: "https://site.com/first", type: "history" }, + { url: "https://site.com/second", type: "history" }, + { url: "https://other", type: "history" }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights.length, 2); + assert.equal(highlights[0].url, links[0].url); + assert.equal(highlights[1].url, links[2].url); + }); + it("should take a bookmark, a pocket, and downloaded item of the same hostname", async () => { + links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + { url: "https://site.com/download", type: "download" }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights.length, 3); + assert.equal(highlights[0].url, links[0].url); + assert.equal(highlights[1].url, links[1].url); + assert.equal(highlights[2].url, links[2].url); + }); + it("should includePocket pocket items when pref is true", async () => { + feed.store.state.Prefs.values["section.highlights.includePocket"] = true; + sandbox.spy(feed.linksCache, "request"); + await feed.fetchHighlights(); + + assert.propertyVal( + feed.linksCache.request.firstCall.args[0], + "excludePocket", + false + ); + }); + it("should not includePocket pocket items when pref is false", async () => { + sandbox.spy(feed.linksCache, "request"); + await feed.fetchHighlights(); + + assert.propertyVal( + feed.linksCache.request.firstCall.args[0], + "excludePocket", + true + ); + }); + it("should not include downloads when includeDownloads pref is false", async () => { + links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + ]; + + // Check that we don't have the downloaded item in highlights + const highlights = await fetchHighlights(); + assert.equal(highlights.length, 2); + assert.equal(highlights[0].url, links[0].url); + assert.equal(highlights[1].url, links[1].url); + }); + it("should include downloads when includeDownloads pref is true", async () => { + feed.store.state.Prefs.values[ + "section.highlights.includeDownloads" + ] = true; + links = [ + { url: "https://site.com/bookmark", type: "bookmark" }, + { url: "https://site.com/pocket", type: "pocket" }, + ]; + + // Check that we did get the downloaded item in highlights + const highlights = await fetchHighlights(); + assert.equal(highlights.length, 3); + assert.equal(highlights[0].url, links[0].url); + assert.equal(highlights[1].url, links[1].url); + assert.equal(highlights[2].url, "https://site.com/download"); + + assert.propertyVal(highlights[2], "type", "download"); + }); + it("should only take 1 download", async () => { + feed.store.state.Prefs.values[ + "section.highlights.includeDownloads" + ] = true; + feed.downloadsManager.getDownloads = () => [ + { url: "https://site1.com/download" }, + { url: "https://site2.com/download" }, + ]; + links = [{ url: "https://site.com/bookmark", type: "bookmark" }]; + + // Check that we did get the most single recent downloaded item in highlights + const highlights = await fetchHighlights(); + assert.equal(highlights.length, 2); + assert.equal(highlights[0].url, links[0].url); + assert.equal(highlights[1].url, "https://site1.com/download"); + }); + it("should sort bookmarks, pocket, and downloads chronologically", async () => { + feed.store.state.Prefs.values[ + "section.highlights.includeDownloads" + ] = true; + feed.downloadsManager.getDownloads = () => [ + { + url: "https://site1.com/download", + type: "download", + date_added: Date.now(), + }, + ]; + links = [ + { + url: "https://site.com/bookmark", + type: "bookmark", + date_added: Date.now() - 10000, + }, + { + url: "https://site2.com/pocket", + type: "pocket", + date_added: Date.now() - 5000, + }, + { + url: "https://site3.com/visited", + type: "history", + date_added: Date.now(), + }, + ]; + + // Check that the higlights are ordered chronologically by their 'date_added' + const highlights = await fetchHighlights(); + assert.equal(highlights.length, 4); + assert.equal(highlights[0].url, "https://site1.com/download"); + assert.equal(highlights[1].url, links[1].url); + assert.equal(highlights[2].url, links[0].url); + assert.equal(highlights[3].url, links[2].url); // history item goes last + }); + it("should set type to bookmark if there is a bookmarkGuid", async () => { + feed.store.state.Prefs.values[ + "section.highlights.includeBookmarks" + ] = true; + links = [ + { + url: "https://mozilla.org", + type: "history", + bookmarkGuid: "1234567890", + }, + ]; + + const highlights = await fetchHighlights(); + + assert.equal(highlights[0].type, "bookmark"); + }); + it("should keep history type if there is a bookmarkGuid but don't include bookmarks", async () => { + feed.store.state.Prefs.values[ + "section.highlights.includeBookmarks" + ] = false; + links = [ + { + url: "https://mozilla.org", + type: "history", + bookmarkGuid: "1234567890", + }, + ]; + + const highlights = await fetchHighlights(); + + assert.propertyVal(highlights[0], "type", "history"); + }); + it("should filter out adult pages", async () => { + filterAdultStub.filter = sinon.stub().returns([]); + const highlights = await fetchHighlights(); + + // The stub filters out everything + assert.calledOnce(filterAdultStub.filter); + assert.equal(highlights.length, 0); + }); + it("should not expose internal link properties", async () => { + const highlights = await fetchHighlights(); + + const internal = Object.keys(highlights[0]).filter(key => + key.startsWith("__") + ); + assert.equal(internal.join(""), ""); + }); + it("should broadcast if feed is not initialized", async () => { + links = []; + await fetchHighlights(); + + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWithExactly( + sectionsManagerStub.updateSection, + SECTION_ID, + { rows: [] }, + true, + undefined + ); + }); + it("should broadcast if options.broadcast is true", async () => { + links = []; + feed.store.state.Sections[0].initialized = true; + await fetchHighlights({ broadcast: true }); + + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWithExactly( + sectionsManagerStub.updateSection, + SECTION_ID, + { rows: [] }, + true, + undefined + ); + }); + it("should not broadcast if options.broadcast is false and initialized is true", async () => { + links = []; + feed.store.state.Sections[0].initialized = true; + await fetchHighlights({ broadcast: false }); + + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWithExactly( + sectionsManagerStub.updateSection, + SECTION_ID, + { rows: [] }, + false, + undefined + ); + }); + }); + describe("#fetchImage", () => { + const FAKE_URL = "https://mozilla.org"; + const FAKE_IMAGE_URL = "https://mozilla.org/preview.jpg"; + function fetchImage(page) { + return feed.fetchImage( + Object.assign({ __sharedCache: { updateLink() {} } }, page) + ); + } + it("should capture the image, if available", async () => { + await fetchImage({ + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }); + + assert.calledOnce(fakeScreenshot.getScreenshotForURL); + assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_IMAGE_URL); + }); + it("should fall back to capturing a screenshot", async () => { + await fetchImage({ url: FAKE_URL }); + + assert.calledOnce(fakeScreenshot.getScreenshotForURL); + assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_URL); + }); + it("should call SectionsManager.updateSectionCard with the right arguments", async () => { + await fetchImage({ + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }); + + assert.calledOnce(sectionsManagerStub.updateSectionCard); + assert.calledWith( + sectionsManagerStub.updateSectionCard, + "highlights", + FAKE_URL, + { image: FAKE_IMAGE }, + true + ); + }); + it("should not update the card with the image", async () => { + const card = { + preview_image_url: FAKE_IMAGE_URL, + url: FAKE_URL, + }; + + await fetchImage(card); + + assert.notProperty(card, "image"); + }); + }); + describe("#uninit", () => { + it("should disable its section", () => { + feed.onAction({ type: at.UNINIT }); + assert.calledOnce(sectionsManagerStub.disableSection); + assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID); + }); + it("should remove the expiration filter", () => { + feed.onAction({ type: at.UNINIT }); + assert.calledOnce(fakePageThumbs.removeExpirationFilter); + }); + it("should remove the sync and Places observers", () => { + feed.onAction({ type: at.UNINIT }); + assert.calledWith( + global.Services.obs.removeObserver, + feed, + SYNC_BOOKMARKS_FINISHED_EVENT + ); + assert.calledWith( + global.Services.obs.removeObserver, + feed, + BOOKMARKS_RESTORE_SUCCESS_EVENT + ); + assert.calledWith( + global.Services.obs.removeObserver, + feed, + BOOKMARKS_RESTORE_FAILED_EVENT + ); + }); + }); + describe("#onAction", () => { + it("should relay all actions to DownloadsManager.onAction", () => { + let action = { + type: at.COPY_DOWNLOAD_LINK, + data: { url: "foo.png" }, + _target: {}, + }; + feed.onAction(action); + assert.calledWith(feed.downloadsManager.onAction, action); + }); + it("should fetch highlights on SYSTEM_TICK", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + feed.onAction({ type: at.SYSTEM_TICK }); + + assert.calledOnce(feed.fetchHighlights); + assert.calledWithExactly(feed.fetchHighlights, { + broadcast: false, + isStartup: false, + }); + }); + it("should fetch highlights on PREF_CHANGED for include prefs", async () => { + feed.fetchHighlights = sinon.spy(); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "section.highlights.includeBookmarks" }, + }); + + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should not fetch highlights on PREF_CHANGED for other prefs", async () => { + feed.fetchHighlights = sinon.spy(); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "section.topstories.pocketCta" }, + }); + + assert.notCalled(feed.fetchHighlights); + }); + it("should fetch highlights on PLACES_HISTORY_CLEARED", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + feed.onAction({ type: at.PLACES_HISTORY_CLEARED }); + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should fetch highlights on DOWNLOAD_CHANGED", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + feed.onAction({ type: at.DOWNLOAD_CHANGED }); + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should fetch highlights on PLACES_LINKS_CHANGED", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + sandbox.stub(feed.linksCache, "expire"); + + feed.onAction({ type: at.PLACES_LINKS_CHANGED }); + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: false }); + assert.calledOnce(feed.linksCache.expire); + }); + it("should fetch highlights on PLACES_LINK_BLOCKED", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + feed.onAction({ type: at.PLACES_LINK_BLOCKED }); + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: true }); + }); + it("should fetch highlights and expire the cache on PLACES_SAVED_TO_POCKET", async () => { + await feed.fetchHighlights(); + feed.fetchHighlights = sinon.spy(); + sandbox.stub(feed.linksCache, "expire"); + + feed.onAction({ type: at.PLACES_SAVED_TO_POCKET }); + assert.calledOnce(feed.fetchHighlights); + assert.calledWith(feed.fetchHighlights, { broadcast: false }); + assert.calledOnce(feed.linksCache.expire); + }); + it("should call fetchHighlights with broadcast false on TOP_SITES_UPDATED", () => { + sandbox.stub(feed, "fetchHighlights"); + feed.onAction({ type: at.TOP_SITES_UPDATED }); + + assert.calledOnce(feed.fetchHighlights); + assert.calledWithExactly(feed.fetchHighlights, { + broadcast: false, + isStartup: false, + }); + }); + it("should call fetchHighlights when deleting or archiving from Pocket", async () => { + feed.fetchHighlights = sinon.spy(); + feed.onAction({ + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + data: { pocket_id: 12345 }, + }); + + assert.calledOnce(feed.fetchHighlights); + assert.calledWithExactly(feed.fetchHighlights, { broadcast: true }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/LinksCache.test.js b/browser/components/newtab/test/unit/lib/LinksCache.test.js new file mode 100644 index 0000000000..8a4d33d2f2 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/LinksCache.test.js @@ -0,0 +1,16 @@ +import { LinksCache } from "lib/LinksCache.sys.mjs"; + +describe("LinksCache", () => { + it("throws when failing request", async () => { + const cache = new LinksCache(); + + let rejected = false; + try { + await cache.request(); + } catch (error) { + rejected = true; + } + + assert(rejected); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js new file mode 100644 index 0000000000..5357290a76 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/MomentsPageHub.test.js @@ -0,0 +1,336 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs"; +import { _MomentsPageHub } from "lib/MomentsPageHub.jsm"; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +describe("MomentsPageHub", () => { + let globals; + let sandbox; + let instance; + let handleMessageRequestStub; + let addImpressionStub; + let blockMessageByIdStub; + let sendTelemetryStub; + let getStringPrefStub; + let setStringPrefStub; + let setIntervalStub; + let clearIntervalStub; + + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _MomentsPageHub(); + const messages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + handleMessageRequestStub = sandbox.stub().resolves(messages); + addImpressionStub = sandbox.stub(); + blockMessageByIdStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + setIntervalStub = sandbox.stub(); + clearIntervalStub = sandbox.stub(); + sendTelemetryStub = sandbox.stub(); + globals.set({ + setInterval: setIntervalStub, + clearInterval: clearIntervalStub, + Services: { + prefs: { + getStringPref: getStringPrefStub, + setStringPref: setStringPrefStub, + }, + telemetry: { + recordEvent: () => {}, + }, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + it("should create an instance", async () => { + setIntervalStub.returns(42); + assert.ok(instance); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + assert.equal(instance.state._intervalId, 42); + }); + + it("should init only once", async () => { + assert.notCalled(handleMessageRequestStub); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledOnce(handleMessageRequestStub); + + instance.uninit(); + + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + }); + + assert.calledTwice(handleMessageRequestStub); + }); + + it("should uninit the instance", () => { + instance.uninit(); + assert.calledOnce(clearIntervalStub); + }); + + it("should setInterval for `checkHomepageOverridePref`", async () => { + await instance.init(sandbox.stub().resolves(), {}); + sandbox.stub(instance, "checkHomepageOverridePref"); + + assert.calledOnce(setIntervalStub); + assert.calledWithExactly(setIntervalStub, sinon.match.func, 5 * 60 * 1000); + + assert.notCalled(instance.checkHomepageOverridePref); + const [cb] = setIntervalStub.firstCall.args; + + cb(); + + assert.calledOnce(instance.checkHomepageOverridePref); + }); + + describe("#messageRequest", () => { + beforeEach(async () => { + await instance.init(Promise.resolve(), { + handleMessageRequest: handleMessageRequestStub, + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + afterEach(() => { + instance.uninit(); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledTwice(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + returnAll: true, + }); + }); + it("shouldn't do anything if no message is provided", async () => { + // Reset the call from `instance.init` + setStringPrefStub.reset(); + handleMessageRequestStub.resolves([]); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(setStringPrefStub); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + it("should record Reach event for the Moments page experiment", async () => { + const momentsMessages = (await PanelTestProvider.getMessages()).filter( + ({ template }) => template === "update_action" + ); + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ...momentsMessages, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.calledOnce(instance.executeAction); + }); + it("should not record the Reach event if it's already sent", async () => { + const messages = [ + { + forReachEvent: { sent: true }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(global.Services.telemetry.recordEvent); + }); + it("should not trigger the action if it's only for the Reach event", async () => { + const messages = [ + { + forReachEvent: { sent: false }, + experimentSlug: "foo", + branchSlug: "bar", + }, + ]; + handleMessageRequestStub.resolves(messages); + sandbox.spy(global.Services.telemetry, "recordEvent"); + sandbox.spy(instance, "executeAction"); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(global.Services.telemetry.recordEvent); + assert.notCalled(instance.executeAction); + }); + }); + describe("executeAction", () => { + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + addImpression: addImpressionStub, + blockMessageById: blockMessageByIdStub, + sendTelemetry: sendTelemetryStub, + }); + }); + it("should set HOMEPAGE_OVERRIDE_PREF on `moments-wnp` action", async () => { + const [msg] = await handleMessageRequestStub(); + sandbox.useFakeTimers(); + instance.executeAction(msg); + + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: instance.getExpirationDate( + msg.content.action.data.expireDelta + ), + }) + ); + }); + it("should block after taking the action", async () => { + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, msg.id); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + instance.executeAction(msg); + + assert.calledOnce(instance.getExpirationDate); + assert.calledWithExactly( + instance.getExpirationDate, + msg.content.action.data.expireDelta + ); + }); + it("should compute expire based on expireDelta", async () => { + sandbox.spy(instance, "getExpirationDate"); + + const [msg] = await handleMessageRequestStub(); + const msgWithExpire = { + ...msg, + content: { + ...msg.content, + action: { + ...msg.content.action, + data: { ...msg.content.action.data, expire: 41 }, + }, + }, + }; + instance.executeAction(msgWithExpire); + + assert.notCalled(instance.getExpirationDate); + assert.calledOnce(setStringPrefStub); + assert.calledWithExactly( + setStringPrefStub, + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ + message_id: msg.id, + url: msg.content.action.data.url, + expire: 41, + }) + ); + }); + it("should send user telemetry", async () => { + const [msg] = await handleMessageRequestStub(); + const sendUserEventTelemetrySpy = sandbox.spy( + instance, + "sendUserEventTelemetry" + ); + instance.executeAction(msg); + + assert.calledOnce(sendTelemetryStub); + assert.calledWithExactly(sendUserEventTelemetrySpy, msg); + assert.calledWithExactly(sendTelemetryStub, { + type: "MOMENTS_PAGE_TELEMETRY", + data: { + action: "moments_user_event", + bucket_id: "WNP_THANK_YOU", + event: "MOMENTS_PAGE_SET", + message_id: "WNP_THANK_YOU", + }, + }); + }); + }); + describe("#checkHomepageOverridePref", () => { + let messageRequestStub; + beforeEach(() => { + messageRequestStub = sandbox.stub(instance, "messageRequest"); + }); + it("should catch parse errors", () => { + getStringPrefStub.returns({}); + + instance.checkHomepageOverridePref(); + + assert.calledOnce(messageRequestStub); + assert.calledWithExactly(messageRequestStub, { + template: "update_action", + triggerId: "momentsUpdate", + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/NewTabInit.test.js b/browser/components/newtab/test/unit/lib/NewTabInit.test.js new file mode 100644 index 0000000000..834409669f --- /dev/null +++ b/browser/components/newtab/test/unit/lib/NewTabInit.test.js @@ -0,0 +1,81 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { NewTabInit } from "lib/NewTabInit.jsm"; + +describe("NewTabInit", () => { + let instance; + let store; + let STATE; + const requestFromTab = portID => + instance.onAction( + ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }, portID) + ); + beforeEach(() => { + STATE = {}; + store = { getState: sinon.stub().returns(STATE), dispatch: sinon.stub() }; + instance = new NewTabInit(); + instance.store = store; + }); + it("should reply with a copy of the state immediately", () => { + requestFromTab(123); + + const resp = ac.AlsoToOneContent( + { type: at.NEW_TAB_INITIAL_STATE, data: STATE }, + 123 + ); + assert.calledWith(store.dispatch, resp); + }); + describe("early / simulated new tabs", () => { + const simulateTabInit = portID => + instance.onAction({ + type: at.NEW_TAB_INIT, + data: { portID, simulated: true }, + }); + beforeEach(() => { + simulateTabInit("foo"); + }); + it("should dispatch if not replied yet", () => { + requestFromTab("foo"); + + assert.calledWith( + store.dispatch, + ac.AlsoToOneContent( + { type: at.NEW_TAB_INITIAL_STATE, data: STATE }, + "foo" + ) + ); + }); + it("should dispatch once for multiple requests", () => { + requestFromTab("foo"); + requestFromTab("foo"); + requestFromTab("foo"); + + assert.calledOnce(store.dispatch); + }); + describe("multiple tabs", () => { + beforeEach(() => { + simulateTabInit("bar"); + }); + it("should dispatch once to each tab", () => { + requestFromTab("foo"); + requestFromTab("bar"); + assert.calledTwice(store.dispatch); + requestFromTab("foo"); + requestFromTab("bar"); + + assert.calledTwice(store.dispatch); + }); + it("should clean up when tabs close", () => { + assert.propertyVal(instance._repliedEarlyTabs, "size", 2); + instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo")); + assert.propertyVal(instance._repliedEarlyTabs, "size", 1); + instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "foo")); + assert.propertyVal(instance._repliedEarlyTabs, "size", 1); + instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "bar")); + assert.propertyVal(instance._repliedEarlyTabs, "size", 0); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersistentCache.test.js b/browser/components/newtab/test/unit/lib/PersistentCache.test.js new file mode 100644 index 0000000000..e645b8d398 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersistentCache.test.js @@ -0,0 +1,142 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PersistentCache } from "lib/PersistentCache.sys.mjs"; + +describe("PersistentCache", () => { + let fakeIOUtils; + let fakePathUtils; + let cache; + let filename = "cache.json"; + let consoleErrorStub; + let globals; + let sandbox; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + fakeIOUtils = { + writeJSON: sinon.stub().resolves(0), + readJSON: sinon.stub().resolves({}), + }; + fakePathUtils = { + join: sinon.stub().returns(filename), + localProfileDir: "/", + }; + consoleErrorStub = sandbox.stub(); + globals.set("console", { error: consoleErrorStub }); + globals.set("IOUtils", fakeIOUtils); + globals.set("PathUtils", fakePathUtils); + + cache = new PersistentCache(filename); + }); + afterEach(() => { + globals.restore(); + sandbox.restore(); + }); + + describe("#get", () => { + it("tries to read the file", async () => { + await cache.get("foo"); + assert.calledOnce(fakeIOUtils.readJSON); + }); + it("doesnt try to read the file if it was already loaded", async () => { + await cache._load(); + fakeIOUtils.readJSON.resetHistory(); + await cache.get("foo"); + assert.notCalled(fakeIOUtils.readJSON); + }); + it("should catch and report errors", async () => { + fakeIOUtils.readJSON.rejects(new SyntaxError("Failed to parse JSON")); + await cache._load(); + assert.calledOnce(consoleErrorStub); + + cache._cache = undefined; + consoleErrorStub.resetHistory(); + + fakeIOUtils.readJSON.rejects( + new DOMException("IOUtils shutting down", "AbortError") + ); + await cache._load(); + assert.calledOnce(consoleErrorStub); + + cache._cache = undefined; + consoleErrorStub.resetHistory(); + + fakeIOUtils.readJSON.rejects( + new DOMException("File not found", "NotFoundError") + ); + await cache._load(); + assert.notCalled(consoleErrorStub); + }); + it("returns data for a given cache key", async () => { + fakeIOUtils.readJSON.resolves({ foo: "bar" }); + let value = await cache.get("foo"); + assert.equal(value, "bar"); + }); + it("returns undefined for a cache key that doesn't exist", async () => { + let value = await cache.get("baz"); + assert.equal(value, undefined); + }); + it("returns all the data if no cache key is specified", async () => { + fakeIOUtils.readJSON.resolves({ foo: "bar" }); + let value = await cache.get(); + assert.deepEqual(value, { foo: "bar" }); + }); + }); + + describe("#set", () => { + it("tries to read the file on the first set", async () => { + await cache.set("foo", { x: 42 }); + assert.calledOnce(fakeIOUtils.readJSON); + }); + it("doesnt try to read the file if it was already loaded", async () => { + cache = new PersistentCache(filename, true); + await cache._load(); + fakeIOUtils.readJSON.resetHistory(); + await cache.set("foo", { x: 42 }); + assert.notCalled(fakeIOUtils.readJSON); + }); + it("sets a string value", async () => { + const key = "testkey"; + const value = "testvalue"; + await cache.set(key, value); + const cachedValue = await cache.get(key); + assert.equal(cachedValue, value); + }); + it("sets an object value", async () => { + const key = "testkey"; + const value = { x: 1, y: 2, z: 3 }; + await cache.set(key, value); + const cachedValue = await cache.get(key); + assert.deepEqual(cachedValue, value); + }); + it("writes the data to file", async () => { + const key = "testkey"; + const value = { x: 1, y: 2, z: 3 }; + + await cache.set(key, value); + assert.calledOnce(fakeIOUtils.writeJSON); + assert.calledWith( + fakeIOUtils.writeJSON, + filename, + { [[key]]: value }, + { tmpPath: `${filename}.tmp` } + ); + }); + it("throws when failing to get file path", async () => { + Object.defineProperty(fakePathUtils, "localProfileDir", { + get() { + throw new Error(); + }, + }); + + let rejected = false; + try { + await cache.set("key", "val"); + } catch (error) { + rejected = true; + } + + assert(rejected); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js new file mode 100644 index 0000000000..0751cafb4f --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NaiveBayesTextTagger.test.js @@ -0,0 +1,95 @@ +import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.jsm"; + +const EPSILON = 0.00001; + +describe("Naive Bayes Tagger", () => { + describe("#tag", () => { + let model = { + model_type: "nb", + positive_class_label: "military", + positive_class_id: 0, + positive_class_threshold_log_prob: -0.5108256237659907, + classes: [ + { + log_prior: -0.6881346387364013, + feature_log_probs: [ + -6.2149425847276, -6.829869141665873, -7.124856122235796, + -7.116661287797188, -6.694751331313906, -7.11798266787003, + -6.5094904366004185, -7.1639509366900604, -7.218981434452414, + -6.854842907887801, -7.080328841624584, + ], + }, + { + log_prior: -0.6981849745899025, + feature_log_probs: [ + -7.0575941199203465, -6.632333513597953, -7.382756370680115, + -7.1160793981275905, -8.467120918791892, -8.369201274990882, + -8.518506617006922, -7.015756380369387, -7.739036845511857, + -9.748294397894645, -3.9353548206941955, + ], + }, + ], + vocab_idfs: { + deal: [0, 5.5058519847862275], + easy: [1, 5.5058519847862275], + tanks: [2, 5.601162164590552], + sites: [3, 5.957837108529285], + care: [4, 5.957837108529285], + needs: [5, 5.824305715904762], + finally: [6, 5.706522680248379], + super: [7, 5.264689927969339], + heard: [8, 5.5058519847862275], + reached: [9, 5.957837108529285], + words: [10, 5.070533913528382], + }, + }; + let instance = new NaiveBayesTextTagger(model, toksToTfIdfVector); + + let testCases = [ + { + input: "Finally! Super easy care for your tanks!", + expected: { + label: "military", + logProb: -0.16299510296630082, + confident: true, + }, + }, + { + input: "heard", + expected: { + label: "military", + logProb: -0.4628170738373294, + confident: false, + }, + }, + { + input: "words", + expected: { + label: null, + logProb: -0.04258339303757985, + confident: false, + }, + }, + ]; + + let checkTag = tc => { + let actual = instance.tagTokens(tokenize(tc.input)); + it(`should tag ${tc.input} with ${tc.expected.label}`, () => { + assert.equal(tc.expected.label, actual.label); + }); + it(`should give ${tc.input} the correct probability`, () => { + let delta = Math.abs(tc.expected.logProb - actual.logProb); + assert.isTrue(delta <= EPSILON); + }); + }; + + // RELEASE THE TESTS! + for (let tc of testCases) { + checkTag(tc); + } + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js new file mode 100644 index 0000000000..fb3abb1367 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/NmfTextTagger.test.js @@ -0,0 +1,479 @@ +import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.jsm"; + +const EPSILON = 0.00001; + +describe("NMF Tagger", () => { + describe("#tag", () => { + // The numbers in this model were pulled from existing trained model. + let model = { + document_topic: { + environment: [ + 0.05313956429537541, 0.07314019377743895, 0.03247190024863182, + 0.016189529772591395, 0.003812317145412572, 0.03863075834647775, + 0.007495425135831521, 0.005100298003919777, 0.005245622179405364, + 0.036196010766427554, 0.02189970342121833, 0.03514130992119014, + 0.001248114096050196, 0.0030908722594824665, 0.0023874256586350626, + 0.008533674814792993, 0.0009424690250135675, 0.01603124888144218, + 0.00752822798092765, 0.0039046678154748796, 0.03521776907836766, + 0.00614546613169027, 0.0008272200196643818, 0.01405638079154697, + 0.001990670259485496, 0.002803666919676377, 0.013841677883061631, + 0.004093362693745272, 0.009310678536276432, 0.006158920150866703, + 0.006821027337091937, 0.002712031105462971, 0.009093298611644996, + 0.014642160500331744, 0.0067239941045715386, 0.007150418784462898, + 0.0064652818600521265, 0.0006735690394489199, 0.02063188588742841, + 0.003213083349614106, 0.0031998068360970093, 0.00264520606931871, + 0.008854824468146531, 0.0024170562884908786, 0.0013705390639746128, + 0.0030575940757273288, 0.010417378215688392, 0.002356164040132228, + 0.0026710154645455007, 0.0007295327370144145, 0.0585307418954327, + 0.0037987763460599574, 0.003199095437138493, 0.004368800434950577, + 0.005087168372751965, 0.0011100904433965942, 0.01700096791869979, + 0.01929226435023826, 0.010536397909643058, 0.001734999985783697, + 0.003852807194017686, 0.007916805773686475, 0.028375307444815964, + 0.0012422599635274355, 0.0009298594944844238, 0.02095410849846837, + 0.0017269844428419192, 0.002152880993141985, 0.0030226616228192387, + 0.004804812297400959, 0.0012383636748462198, 0.006991278216261148, + 0.0013747035300597538, 0.002041541234639563, 0.012076270996247411, + 0.006643837514421182, 0.003974012776560734, 0.015794539051705442, + 0.007601190171659186, 0.016474925942594837, 0.002729423078513777, + 0.007635146179880609, 0.013457547041824648, 0.0007592338429017099, + 0.002947096673767141, 0.006371935735541048, 0.003356178481568716, + 0.00451933490245723, 0.0019006306992329104, 0.013048046603391707, + 0.023541628496101297, 0.027659066125377194, 0.002312727786055524, + 0.0014189157259186062, 0.01963766030236683, 0.0026014761547439634, + 0.002333697870992923, 0.003401734295211338, 0.002522073778255918, + 0.0015769783084977752, + ], + space: [ + 0.045976774394786174, 0.04386532305052323, 0.03346748817597193, + 0.008498345884036708, 0.005802390890667938, 0.0017673346473868704, + 0.00468037374691276, 0.0036807899985757367, 0.0034951488381868424, + 0.015073756869093244, 0.006784747891785806, 0.03069702365741547, + 0.004945214461908244, 0.002527030239506901, 0.0012201743197690308, + 0.010191409658936534, 0.0013882500616525532, 0.014559679471816162, + 0.005308140956577744, 0.002067005832569046, 0.006092496689239475, + 0.0029308442356851265, 0.0006407392160713908, 0.01669972147417425, + 0.0018920321527190246, 0.002436089537269062, 0.05542174181989591, + 0.006448761215865303, 0.012804154851567844, 0.014553974971946687, + 0.004927456148063145, 0.006085620881900181, 0.011626122370522652, + 0.002994267915422563, 0.0038291031528493898, 0.006987917175322377, + 0.00719289436611732, 0.0008398926158042337, 0.019068654506361523, + 0.004453895285397824, 0.00401164781243836, 0.0031309255764704544, + 0.013210118660087334, 0.0015542151889036313, 0.0013951089590218057, + 0.002790924761398501, 0.008739250167366135, 0.0027834569638271025, + 0.09198161284531065, 0.0019488047187835441, 0.001739971582806101, + 0.005113637251322287, 0.12140493794373561, 0.005535368890812829, + 0.004198222617607059, 0.0010670629105233682, 0.005298717616708989, + 0.0048291586850982855, 0.005140125537186181, 0.0011663683373124493, + 0.0024499638218810943, 0.012532772497286819, 0.0015564613278042862, + 0.0012252899339204029, 0.0005095187051357676, 0.0035442657060978655, + 0.014030578705118285, 0.0017653534252553718, 0.004026729875153457, + 0.004002067082856801, 0.00809773970333208, 0.017160384509220625, + 0.002981945110677171, 0.0018338446554387704, 0.0031886913904107484, + 0.004654622711785796, 0.0053886727821435415, 0.009023511029300392, + 0.005246967669202147, 0.022806469628558337, 0.0035142224878495355, + 0.006793295047927272, 0.017396620747821886, 0.000922278971300957, + 0.001695889413253992, 0.007015197552957029, 0.003908581792868586, + 0.010136260994789877, 0.0032880552208979508, 0.0039712539426523625, + 0.009672046620728448, 0.007290428293346, 0.0017814796852793386, + 0.0005388988974780036, 0.013936726486762537, 0.003427738251710856, + 0.002206664729558829, 0.05072392472622557, 0.004424158921356747, + 0.0003680061331891622, + ], + biology: [ + 0.054433533850037796, 0.039689474154513994, 0.027661000660240884, + 0.021655563357213067, 0.007862624595639219, 0.006280655377019006, + 0.013407714984668861, 0.004038592819712647, 0.009652765217013826, + 0.0011353987945632667, 0.00925298156804724, 0.004870163054917538, + 0.04911204317171355, 0.006921538451191124, 0.004003624507234068, + 0.016600722822360296, 0.002179735905957767, 0.010801493818182368, + 0.00918922860910538, 0.022115576350545514, 0.0027720850555002148, + 0.003290714340925284, 0.0006359939927595049, 0.020564054347194806, + 0.019590591011010666, 0.0029008397180383077, 0.030414664509122412, + 0.002864704837438281, 0.030933936414333993, 0.00222576969791357, + 0.007077232390623289, 0.005876547862506722, 0.016917705934608753, + 0.016466207380001166, 0.006648808144677746, 0.017876914915160164, + 0.008216930648675583, 0.0026813239798232098, 0.012171904585413245, + 0.012319763594831614, 0.003909608203628946, 0.003205613981613637, + 0.027729523430009183, 0.0019938396819227074, 0.002752482544417343, + 0.0016746657427111145, 0.019564250521109314, 0.027250898086440583, + 0.000954251437229793, 0.0020431321836649734, 0.0014636128217840221, + 0.006821766389705783, 0.003272989792090916, 0.011086677363737012, + 0.0044279892365732595, 0.0029213721398486203, 0.013081117655947345, + 0.012102962176204816, 0.0029165848047082825, 0.002363073972325097, + 0.0028567640089643695, 0.013692951578614878, 0.0013189478722657382, + 0.0030662419379415885, 0.001688218039583749, 0.0007806438728749603, + 0.025458033834110355, 0.009584308792578437, 0.0033243840056188263, + 0.0068361098488461045, 0.005178034666939756, 0.006831575853694424, + 0.010170774789130092, 0.004639315532453418, 0.00655511046953238, + 0.005661100806175219, 0.006238755352678196, 0.023282136482285103, + 0.007790828526461584, 0.011840304456780202, 0.0021953903460442225, + 0.011205225479328193, 0.01665869590158306, 0.0009257333679666402, + 0.0032380769616003604, 0.007379754534437712, 0.01804771060116468, + 0.02540492978451049, 0.0027900782593570507, 0.0029721824342474694, + 0.005666888959879564, 0.003629523931553047, 0.0017838703067849428, + 0.004996486217852931, 0.006086510142627035, 0.0023570031997685236, + 0.002718397814380002, 0.003908858478916721, 0.02080129902865465, + 0.005591305783253238, + ], + }, + topic_word: [ + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.003173633134427233, 0.0, 0.0, + 0.0019409914586816176, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 5.135548639746091e-5, 0.0, 0.0, 0.0, + 0.00015384770766669982, + ], + [ + 0.0, 0.0, 0.0005001441880557176, 0.0, 0.0, 0.0012069823147301646, + 0.02401141538644239, 8.831990149479376e-5, 0.001813504147854849, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003577161362340021, 0.0005744157863408606, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.002662246533243532, 0.0, 0.0, + 0.0008394369973758684, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 4.768637450522633e-5, 0.0, 0.0, 0.0, 0.0, 0.0010421065429755969, + 0.0, 0.0, 2.3210938729937306e-5, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006034363278588148, + 0.001690622339085902, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.004257728522853072, 0.0, 0.0, 0.0, 0.0], + [ + 0.0007238839225620208, 0.0, 0.0, 0.0, 0.0, 0.0009507496006759083, + 0.0012635532859311572, 0.0, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.2699264109324263e-5, + 0.00032868342552128994, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0011157667743487598, 0.001278875789622101, + 9.011724853181247e-6, 0.0, 3.22069766200917e-5, 0.004124963644732435, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00011961487736485771], + [0.0, 0.0, 0.0, 5.734703813314615e-5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 4.0340264022466226e-5, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.00039701897786057513, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.19635202968946042, 0.0, 0.0008873887898279083, 0.0, + 0.0, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 1.552973162326247e-5, 0.0, + 0.002284331845105356, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.005561738919282601, 0.0, 0.0, 0.0, 0.010700323065082812, + 0.0, 0.0005795117202094265, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0005085828329663487, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.029261090049475084, 0.0020864946050332834, + 0.0018513709831557076, 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008328286790309667, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013227647245223537, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0024010554774254685, 5.357245317969706e-5, 0.0, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014484032312145462, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0012081428144960678, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.000616488580813398, 0.0, 0.0, 0.0017954524796671627, 0.0, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0006660554263924299, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011891151421092303, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0024885434472066534, 0.0, + 0.0010165824086743897, 0.0, 0.0, + ], + [ + 0.0, 5.692292246819767e-5, 0.0, 0.0, 0.001006289633741549, 0.0, 0.0, + 0.001897882990870404, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00010646854330751878, 0.0, + 0.0013480243353754932, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0002608785715957589, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0010620422134845085, 0.0, 0.0, + 0.0002032215308376943, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008928062238389307, 0.0, 0.0, + 5.727265080002417e-5, 0.0, + ], + [ + 0.0, 0.0, 0.06061253593083364, 0.0, 0.02739898181912798, 0.0, 0.0, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0014338134220455178, 0.0, + 0.0011276871850520397, 0.002840121913315777, + ], + [0.0008014293374641945, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.000345858724152025, 0.013078498367906305, 0.0, + 0.002815596608197659, 0.0, 0.0, 0.0030778986683343023, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0010177321509216356, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.00015333347872060042, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0009655934464519347, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0008542046515290346, 0.0, 0.0, + 0.00016472517230317488, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0007759590139787148, + 0.0037535348789227703, 0.0007205740927611773, + ], + [ + 0.0, 0.0, 0.0010313963595627862, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0069665132800572115, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0006880323929924655, 9.207429290830475e-5, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0008404475484102756, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.00016603822882009137, 0.0, 0.0, 0.0, + 0.0004386724451378034, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.003971386830918022, 0.0, 0.0, 0.0, 0.0], + [0.000983926199078037, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.001299108775819868, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.16326515307916822, 0.0, 0.0, 0.0, 0.0, 0.0028677496385613155, + 0.023677620702293598, 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 5.737710913345495e-6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0002081792662367579, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0002840163488982256, + ], + [0.0, 0.0, 0.0, 0.0, 0.0005021534925351664, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.001057424953719077, 0.0, + 0.003578658690485632, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.00022950619982206556, + 0.0018791783657735252, 0.0008530683004027156, 4.5513911743540586e-5, + 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0045523319463242765, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0006160628426134845, 0.0, 0.0023393152617350653, + 0.0, 0.0, 0.0012979890699731222, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.003391399407584813, 0.0, 0.0, 0.000719659722017165, 0.0, + 0.004722518573572638, 0.002758841738663124, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.002127862313876461, 0.0, 0.005031998155190167, + 0.0, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.00055401373160389, 0.0, 0.0, 0.000333325450244618, + 0.0017824446558959168, 0.0011398506826041158, 0.0, + 0.0006366915431430632, + ], + [ + 0.0, 0.21687336139378274, 0.0, 0.0, 0.0, 0.0030345303266644387, 0.0, + 0.0, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0012637173523723526, 0.0, + 0.0010158476831041915, 0.0035425832276585615, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0015451984659512325, 0.019909953764629045, + 0.0013484737840911303, 0.0033472098053086113, 0.0016951819626954759, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00015923419851654453, 0.0, + 0.0024056492047359367, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.01305313280419075, + 0.00014197157780982973, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.000746430999979358, 0.0, + 0.0010041202546700189, 0.004557016648181857, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.00021372865758801545, + 0.00025925151316940747, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.001658746582791234, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.00973640859923001, 0.0012404719999980969, + 0.0006365355864806626, 0.0008291013715577852, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.001473459191608214, 0.0, 0.0, + 0.0009195459918865811, 0.002012929485852207, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0005850456523130979, 0.0, + 0.00014396718214395852, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0011858302272740567, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0046803403116507545, 0.002083219444498354, 0.0, + 0.0, 0.0, 0.006104495765365948, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.005456944646675863, 0.0, + 0.00011428354610339084, 0.0, 0.0, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0013384597578988894, 0.0, 0.0, 0.0, 0.0], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0018450592044551373, 0.0, + 0.005182965872305058, 0.0, 0.0, + ], + [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0003041074021307749, 0.0, + 0.0020827735275448823, 0.0, 0.0008494429669380388, + ], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ], + vocab_idfs: { + blood: [0, 5.0948820521571045], + earth: [1, 4.2248041634380815], + rocket: [2, 5.666668375712782], + brain: [3, 4.616846251214104], + mars: [4, 6.226284163648205], + nothing: [5, 5.270772718620769], + nada: [6, 4.815297189937943], + star: [7, 6.38880309314598], + zilch: [8, 5.889811927026992], + soil: [9, 7.14257489552236], + }, + }; + + let instance = new NmfTextTagger(model, toksToTfIdfVector); + + let testCases = [ + { + input: "blood is in the brain", + expected: { + environment: 0.00037336337061919943, + space: 0.0003307690554984028, + biology: 0.0026549079818439627, + }, + }, + + { + input: "rocket to the star", + expected: { + environment: 0.0002855180592590448, + space: 0.004006242743506598, + biology: 0.0003094182371360131, + }, + }, + { + input: "rocket to the star mars", + expected: { + environment: 0.0004180326651780644, + space: 0.003844259295376754, + biology: 0.0003135623817729136, + }, + }, + { + input: "rocket rocket rocket", + expected: { + environment: 0.00033052002469507015, + space: 0.007519787053895712, + biology: 0.00031862864995569246, + }, + }, + { + input: "nothing nada rocket", + expected: { + environment: 0.0008597524218029812, + space: 0.0035401031629944506, + biology: 0.000950627767326667, + }, + }, + { + input: "rocket", + expected: { + environment: 0.00033052002469507015, + space: 0.007519787053895712, + biology: 0.00031862864995569246, + }, + }, + { + input: "this sentence is out of vocabulary", + expected: { + environment: 0.0, + space: 0.0, + biology: 0.0, + }, + }, + { + input: "this sentence is out of vocabulary except for rocket", + expected: { + environment: 0.00033052002469507015, + space: 0.007519787053895712, + biology: 0.00031862864995569246, + }, + }, + ]; + + let checkTag = tc => { + let actual = instance.tagTokens(tokenize(tc.input)); + it(`should score ${tc.input} correctly`, () => { + Object.keys(actual).forEach(tag => { + let delta = Math.abs(tc.expected[tag] - actual[tag]); + assert.isTrue(delta <= EPSILON); + }); + }); + }; + + // RELEASE THE TESTS! + for (let tc of testCases) { + checkTag(tc); + } + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js new file mode 100644 index 0000000000..833a9d5a7c --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProvider.test.js @@ -0,0 +1,356 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm"; + +describe("Personality Provider", () => { + let instance; + let RemoteSettingsStub; + let RemoteSettingsOnStub; + let RemoteSettingsOffStub; + let RemoteSettingsGetStub; + let sandbox; + let globals; + let baseURLStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + + RemoteSettingsOnStub = sandbox.stub().returns(); + RemoteSettingsOffStub = sandbox.stub().returns(); + RemoteSettingsGetStub = sandbox.stub().returns([]); + + RemoteSettingsStub = name => ({ + get: RemoteSettingsGetStub, + on: RemoteSettingsOnStub, + off: RemoteSettingsOffStub, + }); + + sinon.spy(global, "BasePromiseWorker"); + sinon.spy(global.BasePromiseWorker.prototype, "post"); + + baseURLStub = "https://baseattachmentsurl"; + global.fetch = async server => ({ + ok: true, + json: async () => { + if (server === "bogus://foo/") { + return { capabilities: { attachments: { base_url: baseURLStub } } }; + } + return {}; + }, + }); + globals.set("RemoteSettings", RemoteSettingsStub); + + instance = new PersonalityProvider(); + instance.interestConfig = { + history_item_builder: "history_item_builder", + history_required_fields: ["a", "b", "c"], + interest_finalizer: "interest_finalizer", + item_to_rank_builder: "item_to_rank_builder", + item_ranker: "item_ranker", + interest_combiner: "interest_combiner", + }; + }); + afterEach(() => { + sinon.restore(); + sandbox.restore(); + globals.restore(); + }); + describe("#personalityProviderWorker", () => { + it("should create a new promise worker on first call", async () => { + const { personalityProviderWorker } = instance; + assert.calledOnce(global.BasePromiseWorker); + assert.isDefined(personalityProviderWorker); + }); + it("should cache _personalityProviderWorker on first call", async () => { + instance._personalityProviderWorker = null; + const { personalityProviderWorker } = instance; + assert.isDefined(instance._personalityProviderWorker); + assert.isDefined(personalityProviderWorker); + }); + it("should use old promise worker on second call", async () => { + let { personalityProviderWorker } = instance; + personalityProviderWorker = instance.personalityProviderWorker; + assert.calledOnce(global.BasePromiseWorker); + assert.isDefined(personalityProviderWorker); + }); + }); + describe("#_getBaseAttachmentsURL", () => { + it("should return a fresh value", async () => { + await instance._getBaseAttachmentsURL(); + assert.equal(instance._baseAttachmentsURL, baseURLStub); + }); + it("should return a cached value", async () => { + const cachedURL = "cached"; + instance._baseAttachmentsURL = cachedURL; + await instance._getBaseAttachmentsURL(); + assert.equal(instance._baseAttachmentsURL, cachedURL); + }); + }); + describe("#setup", () => { + it("should setup two sync attachments", () => { + sinon.spy(instance, "setupSyncAttachment"); + instance.setup(); + assert.calledTwice(instance.setupSyncAttachment); + }); + }); + describe("#teardown", () => { + it("should teardown two sync attachments", () => { + sinon.spy(instance, "teardownSyncAttachment"); + instance.teardown(); + assert.calledTwice(instance.teardownSyncAttachment); + }); + it("should terminate worker", () => { + const terminateStub = sandbox.stub().returns(); + instance._personalityProviderWorker = { + terminate: terminateStub, + }; + instance.teardown(); + assert.calledOnce(terminateStub); + }); + }); + describe("#setupSyncAttachment", () => { + it("should call remote settings on twice for setupSyncAttachment", () => { + assert.calledTwice(RemoteSettingsOnStub); + }); + }); + describe("#teardownSyncAttachment", () => { + it("should call remote settings off for teardownSyncAttachment", () => { + instance.teardownSyncAttachment(); + assert.calledOnce(RemoteSettingsOffStub); + }); + }); + describe("#onSync", () => { + it("should call worker onSync", () => { + instance.onSync(); + assert.calledWith(global.BasePromiseWorker.prototype.post, "onSync"); + }); + }); + describe("#getAttachment", () => { + it("should call worker onSync", () => { + instance.getAttachment(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "getAttachment" + ); + }); + }); + describe("#getRecipe", () => { + it("should call worker getRecipe and remote settings get", async () => { + RemoteSettingsGetStub = sandbox.stub().returns([ + { + key: 1, + }, + ]); + sinon.spy(instance, "getAttachment"); + RemoteSettingsStub = name => ({ + get: RemoteSettingsGetStub, + on: RemoteSettingsOnStub, + off: RemoteSettingsOffStub, + }); + globals.set("RemoteSettings", RemoteSettingsStub); + + const result = await instance.getRecipe(); + assert.calledOnce(RemoteSettingsGetStub); + assert.calledOnce(instance.getAttachment); + assert.equal(result.recordKey, 1); + }); + }); + describe("#fetchHistory", () => { + it("should return a history object for fetchHistory", async () => { + const history = await instance.fetchHistory(["requiredColumn"], 1, 1); + assert.equal( + history.sql, + `SELECT url, title, visit_count, frecency, last_visit_date, description\n FROM moz_places\n WHERE last_visit_date >= 1000000\n AND last_visit_date < 1000000 AND IFNULL(requiredColumn, '') <> '' LIMIT 30000` + ); + assert.equal(history.options.columns.length, 1); + assert.equal(Object.keys(history.options.params).length, 0); + }); + }); + describe("#getHistory", () => { + it("should return an empty array", async () => { + instance.interestConfig = { + history_required_fields: [], + }; + const result = await instance.getHistory(); + assert.equal(result.length, 0); + }); + it("should call fetchHistory", async () => { + sinon.spy(instance, "fetchHistory"); + await instance.getHistory(); + }); + }); + describe("#setBaseAttachmentsURL", () => { + it("should call worker setBaseAttachmentsURL", async () => { + await instance.setBaseAttachmentsURL(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "setBaseAttachmentsURL" + ); + }); + }); + describe("#setInterestConfig", () => { + it("should call worker setInterestConfig", async () => { + await instance.setInterestConfig(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "setInterestConfig" + ); + }); + }); + describe("#setInterestVector", () => { + it("should call worker setInterestVector", async () => { + await instance.setInterestVector(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "setInterestVector" + ); + }); + }); + describe("#fetchModels", () => { + it("should call worker fetchModels and remote settings get", async () => { + await instance.fetchModels(); + assert.calledOnce(RemoteSettingsGetStub); + assert.calledWith(global.BasePromiseWorker.prototype.post, "fetchModels"); + }); + }); + describe("#generateTaggers", () => { + it("should call worker generateTaggers", async () => { + await instance.generateTaggers(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "generateTaggers" + ); + }); + }); + describe("#generateRecipeExecutor", () => { + it("should call worker generateRecipeExecutor", async () => { + await instance.generateRecipeExecutor(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "generateRecipeExecutor" + ); + }); + }); + describe("#createInterestVector", () => { + it("should call worker createInterestVector", async () => { + await instance.createInterestVector(); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "createInterestVector" + ); + }); + }); + describe("#init", () => { + it("should return early if setInterestConfig fails", async () => { + sandbox.stub(instance, "setBaseAttachmentsURL").returns(); + sandbox.stub(instance, "setInterestConfig").returns(); + instance.interestConfig = null; + const callback = globals.sandbox.stub(); + await instance.init(callback); + assert.notCalled(callback); + }); + it("should return early if fetchModels fails", async () => { + sandbox.stub(instance, "setBaseAttachmentsURL").returns(); + sandbox.stub(instance, "setInterestConfig").returns(); + sandbox.stub(instance, "fetchModels").resolves({ + ok: false, + }); + const callback = globals.sandbox.stub(); + await instance.init(callback); + assert.notCalled(callback); + }); + it("should return early if createInterestVector fails", async () => { + sandbox.stub(instance, "setBaseAttachmentsURL").returns(); + sandbox.stub(instance, "setInterestConfig").returns(); + sandbox.stub(instance, "fetchModels").resolves({ + ok: true, + }); + sandbox.stub(instance, "generateRecipeExecutor").resolves({ + ok: true, + }); + sandbox.stub(instance, "createInterestVector").resolves({ + ok: false, + }); + const callback = globals.sandbox.stub(); + await instance.init(callback); + assert.notCalled(callback); + }); + it("should call callback on successful init", async () => { + sandbox.stub(instance, "setBaseAttachmentsURL").returns(); + sandbox.stub(instance, "setInterestConfig").returns(); + sandbox.stub(instance, "fetchModels").resolves({ + ok: true, + }); + sandbox.stub(instance, "generateRecipeExecutor").resolves({ + ok: true, + }); + sandbox.stub(instance, "createInterestVector").resolves({ + ok: true, + }); + sandbox.stub(instance, "setInterestVector").resolves(); + const callback = globals.sandbox.stub(); + await instance.init(callback); + assert.calledOnce(callback); + assert.isTrue(instance.initialized); + }); + it("should do generic init stuff when calling init with no cache", async () => { + sandbox.stub(instance, "setBaseAttachmentsURL").returns(); + sandbox.stub(instance, "setInterestConfig").returns(); + sandbox.stub(instance, "fetchModels").resolves({ + ok: true, + }); + sandbox.stub(instance, "generateRecipeExecutor").resolves({ + ok: true, + }); + sandbox.stub(instance, "createInterestVector").resolves({ + ok: true, + interestVector: "interestVector", + }); + sandbox.stub(instance, "setInterestVector").resolves(); + await instance.init(); + assert.calledOnce(instance.setBaseAttachmentsURL); + assert.calledOnce(instance.setInterestConfig); + assert.calledOnce(instance.fetchModels); + assert.calledOnce(instance.generateRecipeExecutor); + assert.calledOnce(instance.createInterestVector); + assert.calledOnce(instance.setInterestVector); + }); + }); + describe("#calculateItemRelevanceScore", () => { + it("should return score for uninitialized provider", async () => { + instance.initialized = false; + assert.equal( + await instance.calculateItemRelevanceScore({ item_score: 2 }), + 2 + ); + }); + it("should return score for initialized provider", async () => { + instance.initialized = true; + + instance._personalityProviderWorker = { + post: (postName, [item]) => ({ + rankingVector: { score: item.item_score }, + }), + }; + + assert.equal( + await instance.calculateItemRelevanceScore({ item_score: 2 }), + 2 + ); + }); + it("should post calculateItemRelevanceScore to PersonalityProviderWorker", async () => { + instance.initialized = true; + await instance.calculateItemRelevanceScore({ item_score: 2 }); + assert.calledWith( + global.BasePromiseWorker.prototype.post, + "calculateItemRelevanceScore" + ); + }); + }); + describe("#getScores", () => { + it("should return correct data for getScores", () => { + const scores = instance.getScores(); + assert.isDefined(scores.interestConfig); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js new file mode 100644 index 0000000000..6dd483ae70 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/PersonalityProviderWorkerClass.test.js @@ -0,0 +1,456 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { PersonalityProviderWorker } from "lib/PersonalityProvider/PersonalityProviderWorkerClass.jsm"; +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.jsm"; +import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm"; +import { NmfTextTagger } from "lib/PersonalityProvider/NmfTextTagger.jsm"; +import { NaiveBayesTextTagger } from "lib/PersonalityProvider/NaiveBayesTextTagger.jsm"; + +describe("Personality Provider Worker Class", () => { + let instance; + let globals; + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + globals.set("tokenize", tokenize); + globals.set("toksToTfIdfVector", toksToTfIdfVector); + globals.set("NaiveBayesTextTagger", NaiveBayesTextTagger); + globals.set("NmfTextTagger", NmfTextTagger); + globals.set("RecipeExecutor", RecipeExecutor); + instance = new PersonalityProviderWorker(); + + // mock the RecipeExecutor + instance.recipeExecutor = { + executeRecipe: (item, recipe) => { + if (recipe === "history_item_builder") { + if (item.title === "fail") { + return null; + } + return { + title: item.title, + score: item.frecency, + type: "history_item", + }; + } else if (recipe === "interest_finalizer") { + return { + title: item.title, + score: item.score * 100, + type: "interest_vector", + }; + } else if (recipe === "item_to_rank_builder") { + if (item.title === "fail") { + return null; + } + return { + item_title: item.title, + item_score: item.score, + type: "item_to_rank", + }; + } else if (recipe === "item_ranker") { + if (item.title === "fail" || item.item_title === "fail") { + return null; + } + return { + title: item.title, + score: item.item_score * item.score, + type: "ranked_item", + }; + } + return null; + }, + executeCombinerRecipe: (item1, item2, recipe) => { + if (recipe === "interest_combiner") { + if ( + item1.title === "combiner_fail" || + item2.title === "combiner_fail" + ) { + return null; + } + if (item1.type === undefined) { + item1.type = "combined_iv"; + } + if (item1.score === undefined) { + item1.score = 0; + } + return { type: item1.type, score: item1.score + item2.score }; + } + return null; + }, + }; + + instance.interestConfig = { + history_item_builder: "history_item_builder", + history_required_fields: ["a", "b", "c"], + interest_finalizer: "interest_finalizer", + item_to_rank_builder: "item_to_rank_builder", + item_ranker: "item_ranker", + interest_combiner: "interest_combiner", + }; + }); + afterEach(() => { + sinon.restore(); + sandbox.restore(); + globals.restore(); + }); + describe("#setBaseAttachmentsURL", () => { + it("should set baseAttachmentsURL", () => { + instance.setBaseAttachmentsURL("url"); + assert.equal(instance.baseAttachmentsURL, "url"); + }); + }); + describe("#setInterestConfig", () => { + it("should set interestConfig", () => { + instance.setInterestConfig("config"); + assert.equal(instance.interestConfig, "config"); + }); + }); + describe("#setInterestVector", () => { + it("should set interestVector", () => { + instance.setInterestVector("vector"); + assert.equal(instance.interestVector, "vector"); + }); + }); + describe("#onSync", async () => { + it("should sync remote settings collection from onSync", async () => { + sinon.stub(instance, "deleteAttachment").resolves(); + sinon.stub(instance, "maybeDownloadAttachment").resolves(); + + instance.onSync({ + data: { + created: ["create-1", "create-2"], + updated: [ + { old: "update-old-1", new: "update-new-1" }, + { old: "update-old-2", new: "update-new-2" }, + ], + deleted: ["delete-2", "delete-1"], + }, + }); + + assert(instance.maybeDownloadAttachment.withArgs("create-1").calledOnce); + assert(instance.maybeDownloadAttachment.withArgs("create-2").calledOnce); + assert( + instance.maybeDownloadAttachment.withArgs("update-new-1").calledOnce + ); + assert( + instance.maybeDownloadAttachment.withArgs("update-new-2").calledOnce + ); + + assert(instance.deleteAttachment.withArgs("delete-1").calledOnce); + assert(instance.deleteAttachment.withArgs("delete-2").calledOnce); + assert(instance.deleteAttachment.withArgs("update-old-1").calledOnce); + assert(instance.deleteAttachment.withArgs("update-old-2").calledOnce); + }); + }); + describe("#maybeDownloadAttachment", () => { + it("should attempt _downloadAttachment three times for maybeDownloadAttachment", async () => { + let existsStub; + let statStub; + let attachmentStub; + sinon.stub(instance, "_downloadAttachment").resolves(); + const makeDirStub = globals.sandbox + .stub(global.IOUtils, "makeDirectory") + .resolves(); + + existsStub = globals.sandbox + .stub(global.IOUtils, "exists") + .resolves(true); + + statStub = globals.sandbox + .stub(global.IOUtils, "stat") + .resolves({ size: "1" }); + + attachmentStub = { + attachment: { + filename: "file", + size: "1", + // This hash matches the hash generated from the empty Uint8Array returned by the IOUtils.read stub. + hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + }; + + await instance.maybeDownloadAttachment(attachmentStub); + assert.calledWith(makeDirStub, "personality-provider"); + assert.calledOnce(existsStub); + assert.calledOnce(statStub); + assert.notCalled(instance._downloadAttachment); + + existsStub.resetHistory(); + statStub.resetHistory(); + instance._downloadAttachment.resetHistory(); + + attachmentStub = { + attachment: { + filename: "file", + size: "2", + }, + }; + + await instance.maybeDownloadAttachment(attachmentStub); + assert.calledThrice(existsStub); + assert.calledThrice(statStub); + assert.calledThrice(instance._downloadAttachment); + + existsStub.resetHistory(); + statStub.resetHistory(); + instance._downloadAttachment.resetHistory(); + + attachmentStub = { + attachment: { + filename: "file", + size: "1", + // Bogus hash to trigger an update. + hash: "1234", + }, + }; + + await instance.maybeDownloadAttachment(attachmentStub); + assert.calledThrice(existsStub); + assert.calledThrice(statStub); + assert.calledThrice(instance._downloadAttachment); + }); + }); + describe("#_downloadAttachment", () => { + beforeEach(() => { + globals.set("Uint8Array", class Uint8Array {}); + }); + it("should write a file from _downloadAttachment", async () => { + globals.set( + "XMLHttpRequest", + class { + constructor() { + this.status = 200; + this.response = "response!"; + } + open() {} + setRequestHeader() {} + send() {} + } + ); + + const ioutilsWriteStub = globals.sandbox + .stub(global.IOUtils, "write") + .resolves(); + + await instance._downloadAttachment({ + attachment: { location: "location", filename: "filename" }, + }); + + const writeArgs = ioutilsWriteStub.firstCall.args; + assert.equal(writeArgs[0], "filename"); + assert.equal(writeArgs[2].tmpPath, "filename.tmp"); + }); + it("should call console.error from _downloadAttachment if not valid response", async () => { + globals.set( + "XMLHttpRequest", + class { + constructor() { + this.status = 0; + this.response = "response!"; + } + open() {} + setRequestHeader() {} + send() {} + } + ); + + const consoleErrorStub = globals.sandbox + .stub(console, "error") + .resolves(); + + await instance._downloadAttachment({ + attachment: { location: "location", filename: "filename" }, + }); + + assert.calledOnce(consoleErrorStub); + }); + }); + describe("#deleteAttachment", () => { + it("should remove attachments when calling deleteAttachment", async () => { + const makeDirStub = globals.sandbox + .stub(global.IOUtils, "makeDirectory") + .resolves(); + const removeStub = globals.sandbox + .stub(global.IOUtils, "remove") + .resolves(); + await instance.deleteAttachment({ attachment: { filename: "filename" } }); + assert.calledOnce(makeDirStub); + assert.calledTwice(removeStub); + assert.calledWith(removeStub.firstCall, "filename", { + ignoreAbsent: true, + }); + assert.calledWith(removeStub.secondCall, "personality-provider", { + ignoreAbsent: true, + }); + }); + }); + describe("#getAttachment", () => { + it("should return JSON when calling getAttachment", async () => { + sinon.stub(instance, "maybeDownloadAttachment").resolves(); + const readJSONStub = globals.sandbox + .stub(global.IOUtils, "readJSON") + .resolves({}); + const record = { attachment: { filename: "filename" } }; + let returnValue = await instance.getAttachment(record); + + assert.calledOnce(readJSONStub); + assert.calledWith(readJSONStub, "filename"); + assert.calledOnce(instance.maybeDownloadAttachment); + assert.calledWith(instance.maybeDownloadAttachment, record); + assert.deepEqual(returnValue, {}); + + readJSONStub.restore(); + globals.sandbox.stub(global.IOUtils, "readJSON").throws("foo"); + const consoleErrorStub = globals.sandbox + .stub(console, "error") + .resolves(); + returnValue = await instance.getAttachment(record); + + assert.calledOnce(consoleErrorStub); + assert.deepEqual(returnValue, {}); + }); + }); + describe("#fetchModels", () => { + it("should return ok true", async () => { + sinon.stub(instance, "getAttachment").resolves(); + const result = await instance.fetchModels([{ key: 1234 }]); + assert.isTrue(result.ok); + assert.deepEqual(instance.models, [{ recordKey: 1234 }]); + }); + it("should return ok false", async () => { + sinon.stub(instance, "getAttachment").resolves(); + const result = await instance.fetchModels([]); + assert.isTrue(!result.ok); + }); + }); + describe("#generateTaggers", () => { + it("should generate taggers from modelKeys", () => { + const modelKeys = ["nb_model_sports", "nmf_model_sports"]; + + instance.models = [ + { recordKey: "nb_model_sports", model_type: "nb" }, + { + recordKey: "nmf_model_sports", + model_type: "nmf", + parent_tag: "nmf_sports_parent_tag", + }, + ]; + + instance.generateTaggers(modelKeys); + assert.equal(instance.taggers.nbTaggers.length, 1); + assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 1); + }); + it("should skip any models not in modelKeys", () => { + const modelKeys = ["nb_model_sports"]; + + instance.models = [ + { recordKey: "nb_model_sports", model_type: "nb" }, + { + recordKey: "nmf_model_sports", + model_type: "nmf", + parent_tag: "nmf_sports_parent_tag", + }, + ]; + + instance.generateTaggers(modelKeys); + assert.equal(instance.taggers.nbTaggers.length, 1); + assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0); + }); + it("should skip any models not defined", () => { + const modelKeys = ["nb_model_sports", "nmf_model_sports"]; + + instance.models = [{ recordKey: "nb_model_sports", model_type: "nb" }]; + instance.generateTaggers(modelKeys); + assert.equal(instance.taggers.nbTaggers.length, 1); + assert.equal(Object.keys(instance.taggers.nmfTaggers).length, 0); + }); + }); + describe("#generateRecipeExecutor", () => { + it("should generate a recipeExecutor", () => { + instance.recipeExecutor = null; + instance.taggers = {}; + instance.generateRecipeExecutor(); + assert.isNotNull(instance.recipeExecutor); + }); + }); + describe("#createInterestVector", () => { + let mockHistory = []; + beforeEach(() => { + mockHistory = [ + { + title: "automotive", + description: "something about automotive", + url: "http://example.com/automotive", + frecency: 10, + }, + { + title: "fashion", + description: "something about fashion", + url: "http://example.com/fashion", + frecency: 5, + }, + { + title: "tech", + description: "something about tech", + url: "http://example.com/tech", + frecency: 1, + }, + ]; + }); + it("should gracefully handle history entries that fail", () => { + mockHistory.push({ title: "fail" }); + assert.isNotNull(instance.createInterestVector(mockHistory)); + }); + + it("should fail if the combiner fails", () => { + mockHistory.push({ title: "combiner_fail", frecency: 111 }); + let actual = instance.createInterestVector(mockHistory); + assert.isNull(actual); + }); + + it("should process history, combine, and finalize", () => { + let actual = instance.createInterestVector(mockHistory); + assert.equal(actual.interestVector.score, 1600); + }); + }); + describe("#calculateItemRelevanceScore", () => { + it("should return null for busted item", () => { + assert.equal( + instance.calculateItemRelevanceScore({ title: "fail" }), + null + ); + }); + it("should return null for a busted ranking", () => { + instance.interestVector = { title: "fail", score: 10 }; + assert.equal( + instance.calculateItemRelevanceScore({ title: "some item", score: 6 }), + null + ); + }); + it("should return a score, and not change with interestVector", () => { + instance.interestVector = { score: 10 }; + assert.equal( + instance.calculateItemRelevanceScore({ score: 2 }).rankingVector.score, + 20 + ); + assert.deepEqual(instance.interestVector, { score: 10 }); + }); + it("should use defined personalization_models if available", () => { + instance.interestVector = { score: 10 }; + const item = { + score: 2, + personalization_models: { + entertainment: 1, + }, + }; + assert.equal( + instance.calculateItemRelevanceScore(item).scorableItem.item_tags + .entertainment, + 1 + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js new file mode 100644 index 0000000000..82a1f2b77a --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/RecipeExecutor.test.js @@ -0,0 +1,1543 @@ +import { RecipeExecutor } from "lib/PersonalityProvider/RecipeExecutor.jsm"; +import { tokenize } from "lib/PersonalityProvider/Tokenize.jsm"; + +class MockTagger { + constructor(mode, tagScoreMap) { + this.mode = mode; + this.tagScoreMap = tagScoreMap; + } + tagTokens(tokens) { + if (this.mode === "nb") { + // eslint-disable-next-line prefer-destructuring + let tag = Object.keys(this.tagScoreMap)[0]; + // eslint-disable-next-line prefer-destructuring + let prob = this.tagScoreMap[tag]; + let conf = prob >= 0.85; + return { + label: tag, + logProb: Math.log(prob), + confident: conf, + }; + } + return this.tagScoreMap; + } + tag(text) { + return this.tagTokens([text]); + } +} + +describe("RecipeExecutor", () => { + let makeItem = () => { + let x = { + lhs: 2, + one: 1, + two: 2, + three: 3, + foo: "FOO", + bar: "BAR", + baz: ["one", "two", "three"], + qux: 42, + text: "This Is A_sentence.", + url: "http://www.wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4", + url2: "http://wonder.example.com/dir1/dir2a-dir2b/dir3+4?key1&key2=val2&key3&%26amp=%3D3+4", + map: { + c: 3, + a: 1, + b: 2, + }, + map2: { + b: 2, + c: 3, + d: 4, + }, + arr1: [2, 3, 4], + arr2: [3, 4, 5], + long: [3, 4, 5, 6, 7], + tags: { + a: { + aa: 0.1, + ab: 0.2, + ac: 0.3, + }, + b: { + ba: 4, + bb: 5, + bc: 6, + }, + }, + bogus: { + a: { + aa: "0.1", + ab: "0.2", + ac: "0.3", + }, + b: { + ba: "4", + bb: "5", + bc: "6", + }, + }, + zero: { + a: 0, + b: 0, + }, + zaro: [0, 0], + }; + return x; + }; + + let EPSILON = 0.00001; + + let instance = new RecipeExecutor( + [ + new MockTagger("nb", { tag1: 0.7 }), + new MockTagger("nb", { tag2: 0.86 }), + new MockTagger("nb", { tag3: 0.9 }), + new MockTagger("nb", { tag5: 0.9 }), + ], + { + tag1: new MockTagger("nmf", { + tag11: 0.9, + tag12: 0.8, + tag13: 0.7, + }), + tag2: new MockTagger("nmf", { + tag21: 0.8, + tag22: 0.7, + tag23: 0.6, + }), + tag3: new MockTagger("nmf", { + tag31: 0.7, + tag32: 0.6, + tag33: 0.5, + }), + tag4: new MockTagger("nmf", { tag41: 0.99 }), + }, + tokenize + ); + let item = null; + + beforeEach(() => { + item = makeItem(); + }); + + describe("#_assembleText", () => { + it("should simply copy a single string", () => { + assert.equal(instance._assembleText(item, ["foo"]), "FOO"); + }); + it("should append some strings with a space", () => { + assert.equal(instance._assembleText(item, ["foo", "bar"]), "FOO BAR"); + }); + it("should give an empty string for a missing field", () => { + assert.equal(instance._assembleText(item, ["missing"]), ""); + }); + it("should not double space an interior missing field", () => { + assert.equal( + instance._assembleText(item, ["foo", "missing", "bar"]), + "FOO BAR" + ); + }); + it("should splice in an array of strings", () => { + assert.equal( + instance._assembleText(item, ["foo", "baz", "bar"]), + "FOO one two three BAR" + ); + }); + it("should handle numbers", () => { + assert.equal( + instance._assembleText(item, ["foo", "qux", "bar"]), + "FOO 42 BAR" + ); + }); + }); + + describe("#naiveBayesTag", () => { + it("should understand NaiveBayesTextTagger", () => { + item = instance.naiveBayesTag(item, { fields: ["text"] }); + assert.isTrue("nb_tags" in item); + assert.isTrue(!("tag1" in item.nb_tags)); + assert.equal(item.nb_tags.tag2, 0.86); + assert.equal(item.nb_tags.tag3, 0.9); + assert.equal(item.nb_tags.tag5, 0.9); + assert.isTrue("nb_tokens" in item); + assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]); + assert.isTrue("nb_tags_extended" in item); + assert.isTrue(!("tag1" in item.nb_tags_extended)); + assert.deepEqual(item.nb_tags_extended.tag2, { + label: "tag2", + logProb: Math.log(0.86), + confident: true, + }); + assert.deepEqual(item.nb_tags_extended.tag3, { + label: "tag3", + logProb: Math.log(0.9), + confident: true, + }); + assert.deepEqual(item.nb_tags_extended.tag5, { + label: "tag5", + logProb: Math.log(0.9), + confident: true, + }); + assert.isTrue("nb_tokens" in item); + assert.deepEqual(item.nb_tokens, ["this", "is", "a", "sentence"]); + }); + }); + + describe("#conditionallyNmfTag", () => { + it("should do nothing if it's not nb tagged", () => { + item = instance.conditionallyNmfTag(item, {}); + assert.equal(item, null); + }); + it("should populate nmf tags for the nb tags", () => { + item = instance.naiveBayesTag(item, { fields: ["text"] }); + item = instance.conditionallyNmfTag(item, {}); + assert.isTrue("nb_tags" in item); + assert.deepEqual(item.nmf_tags, { + tag2: { + tag21: 0.8, + tag22: 0.7, + tag23: 0.6, + }, + tag3: { + tag31: 0.7, + tag32: 0.6, + tag33: 0.5, + }, + }); + assert.deepEqual(item.nmf_tags_parent, { + tag21: "tag2", + tag22: "tag2", + tag23: "tag2", + tag31: "tag3", + tag32: "tag3", + tag33: "tag3", + }); + }); + it("should not populate nmf tags for things that were not nb tagged", () => { + item = instance.naiveBayesTag(item, { fields: ["text"] }); + item = instance.conditionallyNmfTag(item, {}); + assert.isTrue("nmf_tags" in item); + assert.isTrue(!("tag4" in item.nmf_tags)); + assert.isTrue("nmf_tags_parent" in item); + assert.isTrue(!("tag4" in item.nmf_tags_parent)); + }); + }); + + describe("#acceptItemByFieldValue", () => { + it("should implement ==", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "==", + rhsValue: 2, + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "==", + rhsValue: 3, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "==", + rhsField: "two", + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "==", + rhsField: "three", + }) === null + ); + }); + it("should implement !=", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "!=", + rhsValue: 2, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "!=", + rhsValue: 3, + }) !== null + ); + }); + it("should implement < ", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<", + rhsValue: 1, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<", + rhsValue: 2, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<", + rhsValue: 3, + }) !== null + ); + }); + it("should implement <= ", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<=", + rhsValue: 1, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<=", + rhsValue: 2, + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "<=", + rhsValue: 3, + }) !== null + ); + }); + it("should implement > ", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">", + rhsValue: 1, + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">", + rhsValue: 2, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">", + rhsValue: 3, + }) === null + ); + }); + it("should implement >= ", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">=", + rhsValue: 1, + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">=", + rhsValue: 2, + }) !== null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: ">=", + rhsValue: 3, + }) === null + ); + }); + it("should skip items with missing fields", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "no-left", + op: "==", + rhsValue: 1, + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "==", + rhsField: "no-right", + }) === null + ); + assert.isTrue( + instance.acceptItemByFieldValue(item, { field: "lhs", op: "==" }) === + null + ); + }); + it("should skip items with bogus operators", () => { + assert.isTrue( + instance.acceptItemByFieldValue(item, { + field: "lhs", + op: "bogus", + rhsField: "two", + }) === null + ); + }); + }); + + describe("#tokenizeUrl", () => { + it("should strip the leading www from a url", () => { + item = instance.tokenizeUrl(item, { field: "url", dest: "url_toks" }); + assert.deepEqual( + [ + "wonder", + "example", + "com", + "dir1", + "dir2a", + "dir2b", + "dir3", + "4", + "key1", + "key2", + "val2", + "key3", + "amp", + "3", + "4", + ], + item.url_toks + ); + }); + it("should tokenize the not strip the leading non-wwww token from a url", () => { + item = instance.tokenizeUrl(item, { field: "url2", dest: "url_toks" }); + assert.deepEqual( + [ + "wonder", + "example", + "com", + "dir1", + "dir2a", + "dir2b", + "dir3", + "4", + "key1", + "key2", + "val2", + "key3", + "amp", + "3", + "4", + ], + item.url_toks + ); + }); + it("should error for a missing url", () => { + item = instance.tokenizeUrl(item, { field: "missing", dest: "url_toks" }); + assert.equal(item, null); + }); + }); + + describe("#getUrlDomain", () => { + it("should get only the hostname skipping the www", () => { + item = instance.getUrlDomain(item, { field: "url", dest: "url_domain" }); + assert.isTrue("url_domain" in item); + assert.deepEqual("wonder.example.com", item.url_domain); + }); + it("should get only the hostname", () => { + item = instance.getUrlDomain(item, { field: "url2", dest: "url_domain" }); + assert.isTrue("url_domain" in item); + assert.deepEqual("wonder.example.com", item.url_domain); + }); + it("should get the hostname and 2 levels of directories", () => { + item = instance.getUrlDomain(item, { + field: "url", + path_length: 2, + dest: "url_plus_2", + }); + assert.isTrue("url_plus_2" in item); + assert.deepEqual("wonder.example.com/dir1/dir2a-dir2b", item.url_plus_2); + }); + it("should error for a missing url", () => { + item = instance.getUrlDomain(item, { + field: "missing", + dest: "url_domain", + }); + assert.equal(item, null); + }); + }); + + describe("#tokenizeField", () => { + it("should tokenize the field", () => { + item = instance.tokenizeField(item, { field: "text", dest: "toks" }); + assert.isTrue("toks" in item); + assert.deepEqual(["this", "is", "a", "sentence"], item.toks); + }); + it("should error for a missing field", () => { + item = instance.tokenizeField(item, { field: "missing", dest: "toks" }); + assert.equal(item, null); + }); + it("should error for a broken config", () => { + item = instance.tokenizeField(item, {}); + assert.equal(item, null); + }); + }); + + describe("#_typeOf", () => { + it("should know this is a map", () => { + assert.equal(instance._typeOf({}), "map"); + }); + it("should know this is an array", () => { + assert.equal(instance._typeOf([]), "array"); + }); + it("should know this is a string", () => { + assert.equal(instance._typeOf("blah"), "string"); + }); + it("should know this is a boolean", () => { + assert.equal(instance._typeOf(true), "boolean"); + }); + + it("should know this is a null", () => { + assert.equal(instance._typeOf(null), "null"); + }); + }); + + describe("#_lookupScalar", () => { + it("should return the constant", () => { + assert.equal(instance._lookupScalar({}, 1, 0), 1); + }); + it("should return the default", () => { + assert.equal(instance._lookupScalar({}, "blah", 42), 42); + }); + it("should return the field's value", () => { + assert.equal(instance._lookupScalar({ blah: 11 }, "blah", 42), 11); + }); + }); + + describe("#copyValue", () => { + it("should copy values", () => { + item = instance.copyValue(item, { src: "one", dest: "again" }); + assert.isTrue("again" in item); + assert.equal(item.again, 1); + item.one = 100; + assert.equal(item.one, 100); + assert.equal(item.again, 1); + }); + it("should handle maps corrects", () => { + item = instance.copyValue(item, { src: "map", dest: "again" }); + assert.deepEqual(item.again, { a: 1, b: 2, c: 3 }); + item.map.c = 100; + assert.deepEqual(item.again, { a: 1, b: 2, c: 3 }); + item.map = 342; + assert.deepEqual(item.again, { a: 1, b: 2, c: 3 }); + }); + it("should error for a missing field", () => { + item = instance.copyValue(item, { src: "missing", dest: "toks" }); + assert.equal(item, null); + }); + }); + + describe("#keepTopK", () => { + it("should keep the 2 smallest", () => { + item = instance.keepTopK(item, { field: "map", k: 2, descending: false }); + assert.equal(Object.keys(item.map).length, 2); + assert.isTrue("a" in item.map); + assert.equal(item.map.a, 1); + assert.isTrue("b" in item.map); + assert.equal(item.map.b, 2); + assert.isTrue(!("c" in item.map)); + }); + it("should keep the 2 largest", () => { + item = instance.keepTopK(item, { field: "map", k: 2, descending: true }); + assert.equal(Object.keys(item.map).length, 2); + assert.isTrue(!("a" in item.map)); + assert.isTrue("b" in item.map); + assert.equal(item.map.b, 2); + assert.isTrue("c" in item.map); + assert.equal(item.map.c, 3); + }); + it("should still keep the 2 largest", () => { + item = instance.keepTopK(item, { field: "map", k: 2 }); + assert.equal(Object.keys(item.map).length, 2); + assert.isTrue(!("a" in item.map)); + assert.isTrue("b" in item.map); + assert.equal(item.map.b, 2); + assert.isTrue("c" in item.map); + assert.equal(item.map.c, 3); + }); + it("should promote up nested fields", () => { + item = instance.keepTopK(item, { field: "tags", k: 2 }); + assert.equal(Object.keys(item.tags).length, 2); + assert.deepEqual(item.tags, { bb: 5, bc: 6 }); + }); + it("should error for a missing field", () => { + item = instance.keepTopK(item, { field: "missing", k: 3 }); + assert.equal(item, null); + }); + }); + + describe("#scalarMultiply", () => { + it("should use constants", () => { + item = instance.scalarMultiply(item, { field: "map", k: 2 }); + assert.equal(item.map.a, 2); + assert.equal(item.map.b, 4); + assert.equal(item.map.c, 6); + }); + it("should use fields", () => { + item = instance.scalarMultiply(item, { field: "map", k: "three" }); + assert.equal(item.map.a, 3); + assert.equal(item.map.b, 6); + assert.equal(item.map.c, 9); + }); + it("should use default", () => { + item = instance.scalarMultiply(item, { + field: "map", + k: "missing", + dfault: 4, + }); + assert.equal(item.map.a, 4); + assert.equal(item.map.b, 8); + assert.equal(item.map.c, 12); + }); + it("should error for a missing field", () => { + item = instance.scalarMultiply(item, { field: "missing", k: 3 }); + assert.equal(item, null); + }); + it("should multiply numbers", () => { + item = instance.scalarMultiply(item, { field: "lhs", k: 2 }); + assert.equal(item.lhs, 4); + }); + it("should multiply arrays", () => { + item = instance.scalarMultiply(item, { field: "arr1", k: 2 }); + assert.deepEqual(item.arr1, [4, 6, 8]); + }); + it("should should error on strings", () => { + item = instance.scalarMultiply(item, { field: "foo", k: 2 }); + assert.equal(item, null); + }); + }); + + describe("#elementwiseMultiply", () => { + it("should handle maps", () => { + item = instance.elementwiseMultiply(item, { + left: "tags", + right: "map2", + }); + assert.deepEqual(item.tags, { + a: { aa: 0, ab: 0, ac: 0 }, + b: { ba: 8, bb: 10, bc: 12 }, + }); + }); + it("should handle arrays of same length", () => { + item = instance.elementwiseMultiply(item, { + left: "arr1", + right: "arr2", + }); + assert.deepEqual(item.arr1, [6, 12, 20]); + }); + it("should error for arrays of different lengths", () => { + item = instance.elementwiseMultiply(item, { + left: "arr1", + right: "long", + }); + assert.equal(item, null); + }); + it("should error for a missing left", () => { + item = instance.elementwiseMultiply(item, { + left: "missing", + right: "arr2", + }); + assert.equal(item, null); + }); + it("should error for a missing right", () => { + item = instance.elementwiseMultiply(item, { + left: "arr1", + right: "missing", + }); + assert.equal(item, null); + }); + it("should handle numbers", () => { + item = instance.elementwiseMultiply(item, { + left: "three", + right: "two", + }); + assert.equal(item.three, 6); + }); + it("should error for mismatched types", () => { + item = instance.elementwiseMultiply(item, { left: "arr1", right: "two" }); + assert.equal(item, null); + }); + it("should error for strings", () => { + item = instance.elementwiseMultiply(item, { left: "foo", right: "bar" }); + assert.equal(item, null); + }); + }); + + describe("#vectorMultiply", () => { + it("should calculate dot products from maps", () => { + item = instance.vectorMultiply(item, { + left: "map", + right: "map2", + dest: "dot", + }); + assert.equal(item.dot, 13); + }); + it("should calculate dot products from arrays", () => { + item = instance.vectorMultiply(item, { + left: "arr1", + right: "arr2", + dest: "dot", + }); + assert.equal(item.dot, 38); + }); + it("should error for arrays of different lengths", () => { + item = instance.vectorMultiply(item, { left: "arr1", right: "long" }); + assert.equal(item, null); + }); + it("should error for a missing left", () => { + item = instance.vectorMultiply(item, { left: "missing", right: "arr2" }); + assert.equal(item, null); + }); + it("should error for a missing right", () => { + item = instance.vectorMultiply(item, { left: "arr1", right: "missing" }); + assert.equal(item, null); + }); + it("should error for mismatched types", () => { + item = instance.vectorMultiply(item, { left: "arr1", right: "two" }); + assert.equal(item, null); + }); + it("should error for strings", () => { + item = instance.vectorMultiply(item, { left: "foo", right: "bar" }); + assert.equal(item, null); + }); + }); + + describe("#scalarAdd", () => { + it("should error for a missing field", () => { + item = instance.scalarAdd(item, { field: "missing", k: 10 }); + assert.equal(item, null); + }); + it("should error for strings", () => { + item = instance.scalarAdd(item, { field: "foo", k: 10 }); + assert.equal(item, null); + }); + it("should work for numbers", () => { + item = instance.scalarAdd(item, { field: "one", k: 10 }); + assert.equal(item.one, 11); + }); + it("should add a constant to every cell on a map", () => { + item = instance.scalarAdd(item, { field: "map", k: 10 }); + assert.deepEqual(item.map, { a: 11, b: 12, c: 13 }); + }); + it("should add a value from a field to every cell on a map", () => { + item = instance.scalarAdd(item, { field: "map", k: "qux" }); + assert.deepEqual(item.map, { a: 43, b: 44, c: 45 }); + }); + it("should add a constant to every cell on an array", () => { + item = instance.scalarAdd(item, { field: "arr1", k: 10 }); + assert.deepEqual(item.arr1, [12, 13, 14]); + }); + }); + + describe("#vectorAdd", () => { + it("should calculate add vectors from maps", () => { + item = instance.vectorAdd(item, { left: "map", right: "map2" }); + assert.equal(Object.keys(item.map).length, 4); + assert.isTrue("a" in item.map); + assert.equal(item.map.a, 1); + assert.isTrue("b" in item.map); + assert.equal(item.map.b, 4); + assert.isTrue("c" in item.map); + assert.equal(item.map.c, 6); + assert.isTrue("d" in item.map); + assert.equal(item.map.d, 4); + }); + it("should work for missing left", () => { + item = instance.vectorAdd(item, { left: "missing", right: "arr2" }); + assert.deepEqual(item.missing, [3, 4, 5]); + }); + it("should error for missing right", () => { + item = instance.vectorAdd(item, { left: "arr2", right: "missing" }); + assert.equal(item, null); + }); + it("should error error for strings", () => { + item = instance.vectorAdd(item, { left: "foo", right: "bar" }); + assert.equal(item, null); + }); + it("should error for different types", () => { + item = instance.vectorAdd(item, { left: "arr2", right: "map" }); + assert.equal(item, null); + }); + it("should calculate add vectors from arrays", () => { + item = instance.vectorAdd(item, { left: "arr1", right: "arr2" }); + assert.deepEqual(item.arr1, [5, 7, 9]); + }); + it("should abort on different sized arrays", () => { + item = instance.vectorAdd(item, { left: "arr1", right: "long" }); + assert.equal(item, null); + }); + it("should calculate add vectors from arrays", () => { + item = instance.vectorAdd(item, { left: "arr1", right: "arr2" }); + assert.deepEqual(item.arr1, [5, 7, 9]); + }); + }); + + describe("#makeBoolean", () => { + it("should error for missing field", () => { + item = instance.makeBoolean(item, { field: "missing", threshold: 2 }); + assert.equal(item, null); + }); + it("should 0/1 a map", () => { + item = instance.makeBoolean(item, { field: "map", threshold: 2 }); + assert.deepEqual(item.map, { a: 0, b: 0, c: 1 }); + }); + it("should a map of all 1s", () => { + item = instance.makeBoolean(item, { field: "map" }); + assert.deepEqual(item.map, { a: 1, b: 1, c: 1 }); + }); + it("should -1/1 a map", () => { + item = instance.makeBoolean(item, { + field: "map", + threshold: 2, + keep_negative: true, + }); + assert.deepEqual(item.map, { a: -1, b: -1, c: 1 }); + }); + it("should work an array", () => { + item = instance.makeBoolean(item, { field: "arr1", threshold: 3 }); + assert.deepEqual(item.arr1, [0, 0, 1]); + }); + it("should -1/1 an array", () => { + item = instance.makeBoolean(item, { + field: "arr1", + threshold: 3, + keep_negative: true, + }); + assert.deepEqual(item.arr1, [-1, -1, 1]); + }); + it("should 1 a high number", () => { + item = instance.makeBoolean(item, { field: "qux", threshold: 3 }); + assert.equal(item.qux, 1); + }); + it("should 0 a low number", () => { + item = instance.makeBoolean(item, { field: "qux", threshold: 70 }); + assert.equal(item.qux, 0); + }); + it("should -1 a low number", () => { + item = instance.makeBoolean(item, { + field: "qux", + threshold: 83, + keep_negative: true, + }); + assert.equal(item.qux, -1); + }); + it("should fail a string", () => { + item = instance.makeBoolean(item, { field: "foo", threshold: 3 }); + assert.equal(item, null); + }); + }); + + describe("#allowFields", () => { + it("should filter the keys out of a map", () => { + item = instance.allowFields(item, { + fields: ["foo", "missing", "bar"], + }); + assert.deepEqual(item, { foo: "FOO", bar: "BAR" }); + }); + }); + + describe("#filterByValue", () => { + it("should fail on missing field", () => { + item = instance.filterByValue(item, { field: "missing", threshold: 2 }); + assert.equal(item, null); + }); + it("should filter the keys out of a map", () => { + item = instance.filterByValue(item, { field: "map", threshold: 2 }); + assert.deepEqual(item.map, { c: 3 }); + }); + }); + + describe("#l2Normalize", () => { + it("should fail on missing field", () => { + item = instance.l2Normalize(item, { field: "missing" }); + assert.equal(item, null); + }); + it("should L2 normalize an array", () => { + item = instance.l2Normalize(item, { field: "arr1" }); + assert.deepEqual( + item.arr1, + [0.3713906763541037, 0.5570860145311556, 0.7427813527082074] + ); + }); + it("should L2 normalize a map", () => { + item = instance.l2Normalize(item, { field: "map" }); + assert.deepEqual(item.map, { + a: 0.2672612419124244, + b: 0.5345224838248488, + c: 0.8017837257372732, + }); + }); + it("should fail a string", () => { + item = instance.l2Normalize(item, { field: "foo" }); + assert.equal(item, null); + }); + it("should not bomb on a zero vector", () => { + item = instance.l2Normalize(item, { field: "zero" }); + assert.deepEqual(item.zero, { a: 0, b: 0 }); + item = instance.l2Normalize(item, { field: "zaro" }); + assert.deepEqual(item.zaro, [0, 0]); + }); + }); + + describe("#probNormalize", () => { + it("should fail on missing field", () => { + item = instance.probNormalize(item, { field: "missing" }); + assert.equal(item, null); + }); + it("should normalize an array to sum to 1", () => { + item = instance.probNormalize(item, { field: "arr1" }); + assert.deepEqual( + item.arr1, + [0.2222222222222222, 0.3333333333333333, 0.4444444444444444] + ); + }); + it("should normalize a map to sum to 1", () => { + item = instance.probNormalize(item, { field: "map" }); + assert.equal(Object.keys(item.map).length, 3); + assert.isTrue("a" in item.map); + assert.isTrue(Math.abs(item.map.a - 0.16667) <= EPSILON); + assert.isTrue("b" in item.map); + assert.isTrue(Math.abs(item.map.b - 0.33333) <= EPSILON); + assert.isTrue("c" in item.map); + assert.isTrue(Math.abs(item.map.c - 0.5) <= EPSILON); + }); + it("should fail a string", () => { + item = instance.probNormalize(item, { field: "foo" }); + assert.equal(item, null); + }); + it("should not bomb on a zero vector", () => { + item = instance.probNormalize(item, { field: "zero" }); + assert.deepEqual(item.zero, { a: 0, b: 0 }); + item = instance.probNormalize(item, { field: "zaro" }); + assert.deepEqual(item.zaro, [0, 0]); + }); + }); + + describe("#scalarMultiplyTag", () => { + it("should fail on missing field", () => { + item = instance.scalarMultiplyTag(item, { field: "missing", k: 3 }); + assert.equal(item, null); + }); + it("should scalar multiply a nested map", () => { + item = instance.scalarMultiplyTag(item, { + field: "tags", + k: 3, + log_scale: false, + }); + assert.isTrue(Math.abs(item.tags.a.aa - 0.3) <= EPSILON); + assert.isTrue(Math.abs(item.tags.a.ab - 0.6) <= EPSILON); + assert.isTrue(Math.abs(item.tags.a.ac - 0.9) <= EPSILON); + assert.isTrue(Math.abs(item.tags.b.ba - 12) <= EPSILON); + assert.isTrue(Math.abs(item.tags.b.bb - 15) <= EPSILON); + assert.isTrue(Math.abs(item.tags.b.bc - 18) <= EPSILON); + }); + it("should scalar multiply a nested map with logrithms", () => { + item = instance.scalarMultiplyTag(item, { + field: "tags", + k: 3, + log_scale: true, + }); + assert.isTrue( + Math.abs(item.tags.a.aa - Math.log(0.1 + 0.000001) * 3) <= EPSILON + ); + assert.isTrue( + Math.abs(item.tags.a.ab - Math.log(0.2 + 0.000001) * 3) <= EPSILON + ); + assert.isTrue( + Math.abs(item.tags.a.ac - Math.log(0.3 + 0.000001) * 3) <= EPSILON + ); + assert.isTrue( + Math.abs(item.tags.b.ba - Math.log(4.0 + 0.000001) * 3) <= EPSILON + ); + assert.isTrue( + Math.abs(item.tags.b.bb - Math.log(5.0 + 0.000001) * 3) <= EPSILON + ); + assert.isTrue( + Math.abs(item.tags.b.bc - Math.log(6.0 + 0.000001) * 3) <= EPSILON + ); + }); + it("should fail a string", () => { + item = instance.scalarMultiplyTag(item, { field: "foo", k: 3 }); + assert.equal(item, null); + }); + }); + + describe("#setDefault", () => { + it("should store a missing value", () => { + item = instance.setDefault(item, { field: "missing", value: 1111 }); + assert.equal(item.missing, 1111); + }); + it("should not overwrite an existing value", () => { + item = instance.setDefault(item, { field: "lhs", value: 1111 }); + assert.equal(item.lhs, 2); + }); + it("should store a complex value", () => { + item = instance.setDefault(item, { field: "missing", value: { a: 1 } }); + assert.deepEqual(item.missing, { a: 1 }); + }); + }); + + describe("#lookupValue", () => { + it("should promote a value", () => { + item = instance.lookupValue(item, { + haystack: "map", + needle: "c", + dest: "ccc", + }); + assert.equal(item.ccc, 3); + }); + it("should handle a missing haystack", () => { + item = instance.lookupValue(item, { + haystack: "missing", + needle: "c", + dest: "ccc", + }); + assert.isTrue(!("ccc" in item)); + }); + it("should handle a missing needle", () => { + item = instance.lookupValue(item, { + haystack: "map", + needle: "missing", + dest: "ccc", + }); + assert.isTrue(!("ccc" in item)); + }); + }); + + describe("#copyToMap", () => { + it("should copy a value to a map", () => { + item = instance.copyToMap(item, { + src: "qux", + dest_map: "map", + dest_key: "zzz", + }); + assert.isTrue("zzz" in item.map); + assert.equal(item.map.zzz, item.qux); + }); + it("should create a new map to hold the key", () => { + item = instance.copyToMap(item, { + src: "qux", + dest_map: "missing", + dest_key: "zzz", + }); + assert.equal(Object.keys(item.missing).length, 1); + assert.equal(item.missing.zzz, item.qux); + }); + it("should not create an empty map if the src is missing", () => { + item = instance.copyToMap(item, { + src: "missing", + dest_map: "no_map", + dest_key: "zzz", + }); + assert.isTrue(!("no_map" in item)); + }); + }); + + describe("#applySoftmaxTags", () => { + it("should error on missing field", () => { + item = instance.applySoftmaxTags(item, { field: "missing" }); + assert.equal(item, null); + }); + it("should error on nonmaps", () => { + item = instance.applySoftmaxTags(item, { field: "arr1" }); + assert.equal(item, null); + }); + it("should error on unnested maps", () => { + item = instance.applySoftmaxTags(item, { field: "map" }); + assert.equal(item, null); + }); + it("should error on wrong nested maps", () => { + item = instance.applySoftmaxTags(item, { field: "bogus" }); + assert.equal(item, null); + }); + it("should apply softmax across the subtags", () => { + item = instance.applySoftmaxTags(item, { field: "tags" }); + assert.isTrue("a" in item.tags); + assert.isTrue("aa" in item.tags.a); + assert.isTrue("ab" in item.tags.a); + assert.isTrue("ac" in item.tags.a); + assert.isTrue(Math.abs(item.tags.a.aa - 0.30061) <= EPSILON); + assert.isTrue(Math.abs(item.tags.a.ab - 0.33222) <= EPSILON); + assert.isTrue(Math.abs(item.tags.a.ac - 0.36717) <= EPSILON); + + assert.isTrue("b" in item.tags); + assert.isTrue("ba" in item.tags.b); + assert.isTrue("bb" in item.tags.b); + assert.isTrue("bc" in item.tags.b); + assert.isTrue(Math.abs(item.tags.b.ba - 0.09003) <= EPSILON); + assert.isTrue(Math.abs(item.tags.b.bb - 0.24473) <= EPSILON); + assert.isTrue(Math.abs(item.tags.b.bc - 0.66524) <= EPSILON); + }); + }); + + describe("#combinerAdd", () => { + it("should do nothing when right field is missing", () => { + let right = makeItem(); + let combined = instance.combinerAdd(item, right, { field: "missing" }); + assert.deepEqual(combined, item); + }); + it("should handle missing left maps", () => { + let right = makeItem(); + right.missingmap = { a: 5, b: -1, c: 3 }; + let combined = instance.combinerAdd(item, right, { field: "missingmap" }); + assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 }); + }); + it("should add equal sized maps", () => { + let right = makeItem(); + let combined = instance.combinerAdd(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 2, b: 4, c: 6 }); + }); + it("should add long map to short map", () => { + let right = makeItem(); + right.map.d = 999; + let combined = instance.combinerAdd(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 }); + }); + it("should add short map to long map", () => { + let right = makeItem(); + item.map.d = 999; + let combined = instance.combinerAdd(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 2, b: 4, c: 6, d: 999 }); + }); + it("should add equal sized arrays", () => { + let right = makeItem(); + let combined = instance.combinerAdd(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [4, 6, 8]); + }); + it("should handle missing left arrays", () => { + let right = makeItem(); + right.missingarray = [5, 1, 4]; + let combined = instance.combinerAdd(item, right, { + field: "missingarray", + }); + assert.deepEqual(combined.missingarray, [5, 1, 4]); + }); + it("should add long array to short array", () => { + let right = makeItem(); + right.arr1 = [2, 3, 4, 12]; + let combined = instance.combinerAdd(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [4, 6, 8, 12]); + }); + it("should add short array to long array", () => { + let right = makeItem(); + item.arr1 = [2, 3, 4, 12]; + let combined = instance.combinerAdd(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [4, 6, 8, 12]); + }); + it("should handle missing left number", () => { + let right = makeItem(); + right.missingnumber = 999; + let combined = instance.combinerAdd(item, right, { + field: "missingnumber", + }); + assert.deepEqual(combined.missingnumber, 999); + }); + it("should add numbers", () => { + let right = makeItem(); + let combined = instance.combinerAdd(item, right, { field: "lhs" }); + assert.equal(combined.lhs, 4); + }); + it("should error on missing left, and right is a string", () => { + let right = makeItem(); + right.error = "error"; + let combined = instance.combinerAdd(item, right, { field: "error" }); + assert.equal(combined, null); + }); + it("should error on left string", () => { + let right = makeItem(); + let combined = instance.combinerAdd(item, right, { field: "foo" }); + assert.equal(combined, null); + }); + it("should error on mismatch types", () => { + let right = makeItem(); + right.lhs = [1, 2, 3]; + let combined = instance.combinerAdd(item, right, { field: "lhs" }); + assert.equal(combined, null); + }); + }); + + describe("#combinerMax", () => { + it("should do nothing when right field is missing", () => { + let right = makeItem(); + let combined = instance.combinerMax(item, right, { field: "missing" }); + assert.deepEqual(combined, item); + }); + it("should handle missing left maps", () => { + let right = makeItem(); + right.missingmap = { a: 5, b: -1, c: 3 }; + let combined = instance.combinerMax(item, right, { field: "missingmap" }); + assert.deepEqual(combined.missingmap, { a: 5, b: -1, c: 3 }); + }); + it("should handle equal sized maps", () => { + let right = makeItem(); + right.map = { a: 5, b: -1, c: 3 }; + let combined = instance.combinerMax(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 5, b: 2, c: 3 }); + }); + it("should handle short map to long map", () => { + let right = makeItem(); + right.map = { a: 5, b: -1, c: 3, d: 999 }; + let combined = instance.combinerMax(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 }); + }); + it("should handle long map to short map", () => { + let right = makeItem(); + right.map = { a: 5, b: -1, c: 3 }; + item.map.d = 999; + let combined = instance.combinerMax(item, right, { field: "map" }); + assert.deepEqual(combined.map, { a: 5, b: 2, c: 3, d: 999 }); + }); + it("should handle equal sized arrays", () => { + let right = makeItem(); + right.arr1 = [5, 1, 4]; + let combined = instance.combinerMax(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [5, 3, 4]); + }); + it("should handle missing left arrays", () => { + let right = makeItem(); + right.missingarray = [5, 1, 4]; + let combined = instance.combinerMax(item, right, { + field: "missingarray", + }); + assert.deepEqual(combined.missingarray, [5, 1, 4]); + }); + it("should handle short array to long array", () => { + let right = makeItem(); + right.arr1 = [5, 1, 4, 7]; + let combined = instance.combinerMax(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [5, 3, 4, 7]); + }); + it("should handle long array to short array", () => { + let right = makeItem(); + right.arr1 = [5, 1, 4]; + item.arr1.push(7); + let combined = instance.combinerMax(item, right, { field: "arr1" }); + assert.deepEqual(combined.arr1, [5, 3, 4, 7]); + }); + it("should handle missing left number", () => { + let right = makeItem(); + right.missingnumber = 999; + let combined = instance.combinerMax(item, right, { + field: "missingnumber", + }); + assert.deepEqual(combined.missingnumber, 999); + }); + it("should handle big number", () => { + let right = makeItem(); + right.lhs = 99; + let combined = instance.combinerMax(item, right, { field: "lhs" }); + assert.equal(combined.lhs, 99); + }); + it("should handle small number", () => { + let right = makeItem(); + item.lhs = 99; + let combined = instance.combinerMax(item, right, { field: "lhs" }); + assert.equal(combined.lhs, 99); + }); + it("should error on missing left, and right is a string", () => { + let right = makeItem(); + right.error = "error"; + let combined = instance.combinerMax(item, right, { field: "error" }); + assert.equal(combined, null); + }); + it("should error on left string", () => { + let right = makeItem(); + let combined = instance.combinerMax(item, right, { field: "foo" }); + assert.equal(combined, null); + }); + it("should error on mismatch types", () => { + let right = makeItem(); + right.lhs = [1, 2, 3]; + let combined = instance.combinerMax(item, right, { field: "lhs" }); + assert.equal(combined, null); + }); + }); + + describe("#combinerCollectValues", () => { + it("should error on bogus operation", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "missing", + }); + assert.equal(combined, null); + }); + it("should sum when missing left", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "sum", + }); + assert.deepEqual(combined.combined_map, { + "maseratiusa.com/maserati": 41, + }); + }); + it("should sum when missing right", () => { + let right = makeItem(); + item.combined_map = { fake: 42 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "sum", + }); + assert.deepEqual(combined.combined_map, { fake: 42 }); + }); + it("should sum when both", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "sum", + }); + assert.deepEqual(combined.combined_map, { + fake: 42, + "maseratiusa.com/maserati": 82, + }); + }); + + it("should max when missing left", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "max", + }); + assert.deepEqual(combined.combined_map, { + "maseratiusa.com/maserati": 41, + }); + }); + it("should max when missing right", () => { + let right = makeItem(); + item.combined_map = { fake: 42 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "max", + }); + assert.deepEqual(combined.combined_map, { fake: 42 }); + }); + it("should max when both (right)", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 99; + item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "max", + }); + assert.deepEqual(combined.combined_map, { + fake: 42, + "maseratiusa.com/maserati": 99, + }); + }); + it("should max when both (left)", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = -99; + item.combined_map = { fake: 42, "maseratiusa.com/maserati": 41 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "max", + }); + assert.deepEqual(combined.combined_map, { + fake: 42, + "maseratiusa.com/maserati": 41, + }); + }); + + it("should overwrite when missing left", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "overwrite", + }); + assert.deepEqual(combined.combined_map, { + "maseratiusa.com/maserati": 41, + }); + }); + it("should overwrite when missing right", () => { + let right = makeItem(); + item.combined_map = { fake: 42 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "overwrite", + }); + assert.deepEqual(combined.combined_map, { fake: 42 }); + }); + it("should overwrite when both", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + item.combined_map = { fake: 42, "maseratiusa.com/maserati": 77 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "overwrite", + }); + assert.deepEqual(combined.combined_map, { + fake: 42, + "maseratiusa.com/maserati": 41, + }); + }); + + it("should count when missing left", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "count", + }); + assert.deepEqual(combined.combined_map, { + "maseratiusa.com/maserati": 1, + }); + }); + it("should count when missing right", () => { + let right = makeItem(); + item.combined_map = { fake: 42 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "count", + }); + assert.deepEqual(combined.combined_map, { fake: 42 }); + }); + it("should count when both", () => { + let right = makeItem(); + right.url_domain = "maseratiusa.com/maserati"; + right.time = 41; + item.combined_map = { fake: 42, "maseratiusa.com/maserati": 1 }; + let combined = instance.combinerCollectValues(item, right, { + left_field: "combined_map", + right_key_field: "url_domain", + right_value_field: "time", + operation: "count", + }); + assert.deepEqual(combined.combined_map, { + fake: 42, + "maseratiusa.com/maserati": 2, + }); + }); + }); + + describe("#executeRecipe", () => { + it("should handle working steps", () => { + let final = instance.executeRecipe({}, [ + { function: "set_default", field: "foo", value: 1 }, + { function: "set_default", field: "bar", value: 10 }, + ]); + assert.equal(final.foo, 1); + assert.equal(final.bar, 10); + }); + it("should handle unknown steps", () => { + let final = instance.executeRecipe({}, [ + { function: "set_default", field: "foo", value: 1 }, + { function: "missing" }, + { function: "set_default", field: "bar", value: 10 }, + ]); + assert.equal(final, null); + }); + it("should handle erroring steps", () => { + let final = instance.executeRecipe({}, [ + { function: "set_default", field: "foo", value: 1 }, + { + function: "accept_item_by_field_value", + field: "missing", + op: "invalid", + rhsField: "moot", + rhsValue: "m00t", + }, + { function: "set_default", field: "bar", value: 10 }, + ]); + assert.equal(final, null); + }); + }); + + describe("#executeCombinerRecipe", () => { + it("should handle working steps", () => { + let final = instance.executeCombinerRecipe( + { foo: 1, bar: 10 }, + { foo: 1, bar: 10 }, + [ + { function: "combiner_add", field: "foo" }, + { function: "combiner_add", field: "bar" }, + ] + ); + assert.equal(final.foo, 2); + assert.equal(final.bar, 20); + }); + it("should handle unknown steps", () => { + let final = instance.executeCombinerRecipe( + { foo: 1, bar: 10 }, + { foo: 1, bar: 10 }, + [ + { function: "combiner_add", field: "foo" }, + { function: "missing" }, + { function: "combiner_add", field: "bar" }, + ] + ); + assert.equal(final, null); + }); + it("should handle erroring steps", () => { + let final = instance.executeCombinerRecipe( + { foo: 1, bar: 10, baz: 0 }, + { foo: 1, bar: 10, baz: "hundred" }, + [ + { function: "combiner_add", field: "foo" }, + { function: "combiner_add", field: "baz" }, + { function: "combiner_add", field: "bar" }, + ] + ); + assert.equal(final, null); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js new file mode 100644 index 0000000000..8503c2903b --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PersonalityProvider/Tokenize.test.js @@ -0,0 +1,134 @@ +import { + tokenize, + toksToTfIdfVector, +} from "lib/PersonalityProvider/Tokenize.jsm"; + +const EPSILON = 0.00001; + +describe("TF-IDF Term Vectorizer", () => { + describe("#tokenize", () => { + let testCases = [ + { input: "HELLO there", expected: ["hello", "there"] }, + { input: "blah,,,blah,blah", expected: ["blah", "blah", "blah"] }, + { + input: "Call Jenny: 967-5309", + expected: ["call", "jenny", "967", "5309"], + }, + { + input: "Yo(what)[[hello]]{{jim}}}bob{1:2:1+2=$3", + expected: [ + "yo", + "what", + "hello", + "jim", + "bob", + "1", + "2", + "1", + "2", + "3", + ], + }, + { input: "čÄfė 80's", expected: ["čäfė", "80", "s"] }, + { input: "我知道很多东西。", expected: ["我知道很多东西"] }, + ]; + let checkTokenization = tc => { + it(`${tc.input} should tokenize to ${tc.expected}`, () => { + assert.deepEqual(tc.expected, tokenize(tc.input)); + }); + }; + + for (let i = 0; i < testCases.length; i++) { + checkTokenization(testCases[i]); + } + }); + + describe("#tfidf", () => { + let vocab_idfs = { + deal: [221, 5.5058519847862275], + easy: [269, 5.5058519847862275], + tanks: [867, 5.601162164590552], + sites: [792, 5.957837108529285], + care: [153, 5.957837108529285], + needs: [596, 5.824305715904762], + finally: [334, 5.706522680248379], + }; + let testCases = [ + { + input: "Finally! Easy care for your tanks!", + expected: { + finally: [334, 0.5009816295853761], + easy: [269, 0.48336453811728713], + care: [153, 0.5230447876368227], + tanks: [867, 0.49173191907236774], + }, + }, + { + input: "Easy easy EASY", + expected: { easy: [269, 1.0] }, + }, + { + input: "Easy easy care", + expected: { + easy: [269, 0.8795205218806832], + care: [153, 0.4758609582543317], + }, + }, + { + input: "easy care", + expected: { + easy: [269, 0.6786999710383944], + care: [153, 0.7344156515982504], + }, + }, + { + input: "这个空间故意留空。", + expected: { + /* This space is left intentionally blank. */ + }, + }, + ]; + let checkTokenGeneration = tc => { + describe(`${tc.input} should have only vocabulary tokens`, () => { + let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs); + + it(`${tc.input} should generate exactly ${Object.keys( + tc.expected + )}`, () => { + let seen = {}; + Object.keys(actual).forEach(actualTok => { + assert.isTrue(actualTok in tc.expected); + seen[actualTok] = true; + }); + Object.keys(tc.expected).forEach(expectedTok => { + assert.isTrue(expectedTok in seen); + }); + }); + + it(`${tc.input} should have the correct token ids`, () => { + Object.keys(actual).forEach(actualTok => { + assert.equal(tc.expected[actualTok][0], actual[actualTok][0]); + }); + }); + }); + }; + + let checkTfIdfVector = tc => { + let actual = toksToTfIdfVector(tokenize(tc.input), vocab_idfs); + it(`${tc.input} should have the correct tf-idf`, () => { + Object.keys(actual).forEach(actualTok => { + let delta = Math.abs( + tc.expected[actualTok][1] - actual[actualTok][1] + ); + assert.isTrue(delta <= EPSILON); + }); + }); + }; + + // run the tests + for (let i = 0; i < testCases.length; i++) { + checkTokenGeneration(testCases[i]); + checkTfIdfVector(testCases[i]); + } + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js new file mode 100644 index 0000000000..20210ab7b1 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js @@ -0,0 +1,1245 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import injector from "inject!lib/PlacesFeed.jsm"; + +const FAKE_BOOKMARK = { + bookmarkGuid: "xi31", + bookmarkTitle: "Foo", + dateAdded: 123214232, + url: "foo.com", +}; +const TYPE_BOOKMARK = 0; // This is fake, for testing +const SOURCES = { + DEFAULT: 0, + SYNC: 1, + IMPORT: 2, + RESTORE: 5, + RESTORE_ON_STARTUP: 6, +}; + +const BLOCKED_EVENT = "newtab-linkBlocked"; // The event dispatched in NewTabUtils when a link is blocked; + +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const POCKET_SITE_PREF = "extensions.pocket.site"; + +describe("PlacesFeed", () => { + let PlacesFeed; + let PlacesObserver; + let globals; + let sandbox; + let feed; + let shortURLStub; + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + globals.set("NewTabUtils", { + activityStreamProvider: { getBookmark() {} }, + activityStreamLinks: { + addBookmark: sandbox.spy(), + deleteBookmark: sandbox.spy(), + deleteHistoryEntry: sandbox.spy(), + blockURL: sandbox.spy(), + addPocketEntry: sandbox.spy(() => Promise.resolve()), + deletePocketEntry: sandbox.spy(() => Promise.resolve()), + archivePocketEntry: sandbox.spy(() => Promise.resolve()), + }, + }); + globals.set("pktApi", { + isUserLoggedIn: sandbox.spy(), + }); + globals.set("ExperimentAPI", { + getExperiment: sandbox.spy(), + }); + globals.set("NimbusFeatures", { + pocketNewtab: { + getVariable: sandbox.spy(), + }, + }); + globals.set("PartnerLinkAttribution", { + makeRequest: sandbox.spy(), + }); + sandbox + .stub(global.PlacesUtils.bookmarks, "TYPE_BOOKMARK") + .value(TYPE_BOOKMARK); + sandbox.stub(global.PlacesUtils.bookmarks, "SOURCES").value(SOURCES); + sandbox.spy(global.PlacesUtils.history, "addObserver"); + sandbox.spy(global.PlacesUtils.history, "removeObserver"); + sandbox.spy(global.PlacesUtils.observers, "addListener"); + sandbox.spy(global.PlacesUtils.observers, "removeListener"); + sandbox.spy(global.Services.obs, "addObserver"); + sandbox.spy(global.Services.obs, "removeObserver"); + sandbox.spy(global.console, "error"); + shortURLStub = sandbox + .stub() + .callsFake(site => + site.url.replace(/(.com|.ca)/, "").replace("https://", "") + ); + + global.Services.io.newURI = spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + scheme: "https", + }); + + global.Cc["@mozilla.org/timer;1"] = { + createInstance() { + return { + initWithCallback: sinon.stub().callsFake(callback => callback()), + cancel: sinon.spy(), + }; + }, + }; + ({ PlacesFeed } = injector({ + "lib/ShortURL.jsm": { shortURL: shortURLStub }, + })); + PlacesObserver = PlacesFeed.PlacesObserver; + feed = new PlacesFeed(); + feed.store = { dispatch: sinon.spy() }; + globals.set("AboutNewTab", { + activityStream: { store: { feeds: { get() {} } } }, + }); + }); + afterEach(() => { + globals.restore(); + sandbox.restore(); + }); + + it("should have a PlacesObserver that dispatches to the store", () => { + assert.instanceOf(feed.placesObserver, PlacesObserver); + const action = { type: "FOO" }; + + feed.placesObserver.dispatch(action); + + assert.calledOnce(feed.store.dispatch); + assert.equal(feed.store.dispatch.firstCall.args[0].type, action.type); + }); + + describe("#addToBlockedTopSitesSponsors", () => { + let spy; + beforeEach(() => { + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) + .returns(`["foo","bar"]`); + spy = sandbox.spy(global.Services.prefs, "setStringPref"); + }); + + it("should add the blocked sponsors to the blocklist", () => { + feed.addToBlockedTopSitesSponsors([ + { url: "test.com" }, + { url: "test1.com" }, + ]); + + assert.calledOnce(spy); + const [, sponsors] = spy.firstCall.args; + assert.deepEqual( + new Set(["foo", "bar", "test", "test1"]), + new Set(JSON.parse(sponsors)) + ); + }); + + it("should not add duplicate sponsors to the blocklist", () => { + feed.addToBlockedTopSitesSponsors([ + { url: "foo.com" }, + { url: "bar.com" }, + { url: "test.com" }, + ]); + + assert.calledOnce(spy); + const [, sponsors] = spy.firstCall.args; + assert.deepEqual( + new Set(["foo", "bar", "test"]), + new Set(JSON.parse(sponsors)) + ); + }); + }); + + describe("#onAction", () => { + it("should add bookmark, history, places, blocked observers on INIT", () => { + feed.onAction({ type: at.INIT }); + + assert.calledWith( + global.PlacesUtils.observers.addListener, + [ + "bookmark-added", + "bookmark-removed", + "history-cleared", + "page-removed", + ], + feed.placesObserver.handlePlacesEvent + ); + assert.calledWith(global.Services.obs.addObserver, feed, BLOCKED_EVENT); + }); + it("should remove bookmark, history, places, blocked observers, and timers on UNINIT", () => { + feed.placesChangedTimer = + global.Cc["@mozilla.org/timer;1"].createInstance(); + let spy = feed.placesChangedTimer.cancel; + feed.onAction({ type: at.UNINIT }); + + assert.calledWith( + global.PlacesUtils.observers.removeListener, + [ + "bookmark-added", + "bookmark-removed", + "history-cleared", + "page-removed", + ], + feed.placesObserver.handlePlacesEvent + ); + assert.calledWith( + global.Services.obs.removeObserver, + feed, + BLOCKED_EVENT + ); + assert.equal(feed.placesChangedTimer, null); + assert.calledOnce(spy); + }); + it("should block a url on BLOCK_URL", () => { + feed.onAction({ + type: at.BLOCK_URL, + data: [{ url: "apple.com", pocket_id: 1234 }], + }); + assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, { + url: "apple.com", + pocket_id: 1234, + }); + }); + it("should update the blocked top sites sponsors", () => { + sandbox.stub(feed, "addToBlockedTopSitesSponsors"); + feed.onAction({ + type: at.BLOCK_URL, + data: [{ url: "foo.com", pocket_id: 1234, isSponsoredTopSite: 1 }], + }); + assert.calledWith(feed.addToBlockedTopSitesSponsors, [ + { url: "foo.com" }, + ]); + }); + it("should bookmark a url on BOOKMARK_URL", () => { + const data = { url: "pear.com", title: "A pear" }; + const _target = { browser: { ownerGlobal() {} } }; + feed.onAction({ type: at.BOOKMARK_URL, data, _target }); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.addBookmark, + data, + _target.browser.ownerGlobal + ); + }); + it("should delete a bookmark on DELETE_BOOKMARK_BY_ID", () => { + feed.onAction({ type: at.DELETE_BOOKMARK_BY_ID, data: "g123kd" }); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.deleteBookmark, + "g123kd" + ); + }); + it("should delete a history entry on DELETE_HISTORY_URL", () => { + feed.onAction({ + type: at.DELETE_HISTORY_URL, + data: { url: "guava.com", forceBlock: null }, + }); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, + "guava.com" + ); + assert.notCalled(global.NewTabUtils.activityStreamLinks.blockURL); + }); + it("should delete a history entry on DELETE_HISTORY_URL and force a site to be blocked if specified", () => { + feed.onAction({ + type: at.DELETE_HISTORY_URL, + data: { url: "guava.com", forceBlock: "g123kd" }, + }); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.deleteHistoryEntry, + "guava.com" + ); + assert.calledWith(global.NewTabUtils.activityStreamLinks.blockURL, { + url: "guava.com", + pocket_id: undefined, + }); + }); + it("should call openTrustedLinkIn with the correct url, where and params on OPEN_NEW_WINDOW", () => { + const openTrustedLinkIn = sinon.stub(); + const openWindowAction = { + type: at.OPEN_NEW_WINDOW, + data: { url: "https://foo.com" }, + _target: { browser: { ownerGlobal: { openTrustedLinkIn } } }, + }; + + feed.onAction(openWindowAction); + + assert.calledOnce(openTrustedLinkIn); + const [url, where, params] = openTrustedLinkIn.firstCall.args; + assert.equal(url, "https://foo.com"); + assert.equal(where, "window"); + assert.propertyVal(params, "private", false); + assert.propertyVal(params, "forceForeground", false); + }); + it("should call openTrustedLinkIn with the correct url, where, params and privacy args on OPEN_PRIVATE_WINDOW", () => { + const openTrustedLinkIn = sinon.stub(); + const openWindowAction = { + type: at.OPEN_PRIVATE_WINDOW, + data: { url: "https://foo.com" }, + _target: { browser: { ownerGlobal: { openTrustedLinkIn } } }, + }; + + feed.onAction(openWindowAction); + + assert.calledOnce(openTrustedLinkIn); + const [url, where, params] = openTrustedLinkIn.firstCall.args; + assert.equal(url, "https://foo.com"); + assert.equal(where, "window"); + assert.propertyVal(params, "private", true); + assert.propertyVal(params, "forceForeground", false); + }); + it("should call openTrustedLinkIn with the correct url, where and params on OPEN_LINK", () => { + const openTrustedLinkIn = sinon.stub(); + const openLinkAction = { + type: at.OPEN_LINK, + data: { url: "https://foo.com" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + assert.calledOnce(openTrustedLinkIn); + const [url, where, params] = openTrustedLinkIn.firstCall.args; + assert.equal(url, "https://foo.com"); + assert.equal(where, "current"); + assert.propertyVal(params, "private", false); + assert.propertyVal(params, "forceForeground", false); + }); + it("should open link with referrer on OPEN_LINK", () => { + const openTrustedLinkIn = sinon.stub(); + const openLinkAction = { + type: at.OPEN_LINK, + data: { url: "https://foo.com", referrer: "https://foo.com/ref" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + const [, , params] = openTrustedLinkIn.firstCall.args; + assert.nestedPropertyVal(params, "referrerInfo.referrerPolicy", 5); + assert.nestedPropertyVal( + params, + "referrerInfo.originalReferrer.spec", + "https://foo.com/ref" + ); + }); + it("should mark link with typed bonus as typed before opening OPEN_LINK", () => { + const callOrder = []; + sinon + .stub(global.PlacesUtils.history, "markPageAsTyped") + .callsFake(() => { + callOrder.push("markPageAsTyped"); + }); + const openTrustedLinkIn = sinon.stub().callsFake(() => { + callOrder.push("openTrustedLinkIn"); + }); + const openLinkAction = { + type: at.OPEN_LINK, + data: { + typedBonus: true, + url: "https://foo.com", + }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "tab" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + assert.sameOrderedMembers(callOrder, [ + "markPageAsTyped", + "openTrustedLinkIn", + ]); + }); + it("should open the pocket link if it's a pocket story on OPEN_LINK", () => { + const openTrustedLinkIn = sinon.stub(); + const openLinkAction = { + type: at.OPEN_LINK, + data: { + url: "https://foo.com", + open_url: "getpocket.com/foo", + type: "pocket", + }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + + feed.onAction(openLinkAction); + + assert.calledOnce(openTrustedLinkIn); + const [url, where, params] = openTrustedLinkIn.firstCall.args; + assert.equal(url, "getpocket.com/foo"); + assert.equal(where, "current"); + assert.propertyVal(params, "private", false); + }); + it("should not open link if not http", () => { + const openTrustedLinkIn = sinon.stub(); + global.Services.io.newURI = spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + scheme: "file", + }); + const openLinkAction = { + type: at.OPEN_LINK, + data: { url: "file:///foo.com" }, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn, whereToOpenLink: e => "current" }, + }, + }, + }; + + feed.onAction(openLinkAction); + const [e] = global.console.error.firstCall.args; + assert.equal( + e.message, + "Can't open link using file protocol from the new tab page." + ); + }); + it("should call fillSearchTopSiteTerm on FILL_SEARCH_TERM", () => { + sinon.stub(feed, "fillSearchTopSiteTerm"); + + feed.onAction({ type: at.FILL_SEARCH_TERM }); + + assert.calledOnce(feed.fillSearchTopSiteTerm); + }); + it("should call openTrustedLinkIn with the correct SUMO url on ABOUT_SPONSORED_TOP_SITES", () => { + const openTrustedLinkIn = sinon.stub(); + const openLinkAction = { + type: at.ABOUT_SPONSORED_TOP_SITES, + _target: { + browser: { + ownerGlobal: { openTrustedLinkIn }, + }, + }, + }; + + feed.onAction(openLinkAction); + + assert.calledOnce(openTrustedLinkIn); + const [url, where] = openTrustedLinkIn.firstCall.args; + assert.equal(url.endsWith("sponsor-privacy"), true); + assert.equal(where, "tab"); + }); + it("should set the URL bar value to the label value", async () => { + const locationBar = { search: sandbox.stub() }; + const action = { + type: at.FILL_SEARCH_TERM, + data: { label: "@Foo" }, + _target: { browser: { ownerGlobal: { gURLBar: locationBar } } }, + }; + + await feed.fillSearchTopSiteTerm(action); + + assert.calledOnce(locationBar.search); + assert.calledWithExactly(locationBar.search, "@Foo", { + searchEngine: null, + searchModeEntry: "topsites_newtab", + }); + }); + it("should call saveToPocket on SAVE_TO_POCKET", () => { + const action = { + type: at.SAVE_TO_POCKET, + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + sinon.stub(feed, "saveToPocket"); + feed.onAction(action); + assert.calledWithExactly( + feed.saveToPocket, + action.data.site, + action._target.browser + ); + }); + it("should openTrustedLinkIn with sendToPocket if not logged in", () => { + const openTrustedLinkIn = sinon.stub(); + global.NimbusFeatures.pocketNewtab.getVariable = sandbox + .stub() + .returns(true); + global.pktApi.isUserLoggedIn = sandbox.stub().returns(false); + global.ExperimentAPI.getExperiment = sandbox.stub().returns({ + slug: "slug", + branch: { slug: "branch-slug" }, + }); + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs(POCKET_SITE_PREF) + .returns("getpocket.com"); + const action = { + type: at.SAVE_TO_POCKET, + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { + browser: { + ownerGlobal: { + openTrustedLinkIn, + }, + }, + }, + }; + feed.onAction(action); + assert.calledOnce(openTrustedLinkIn); + const [url, where] = openTrustedLinkIn.firstCall.args; + assert.equal( + url, + "https://getpocket.com/signup?utm_source=firefox_newtab_save_button&utm_campaign=slug&utm_content=branch-slug" + ); + assert.equal(where, "tab"); + }); + it("should call NewTabUtils.activityStreamLinks.addPocketEntry if we are saving a pocket story", async () => { + const action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + await feed.saveToPocket(action.data.site, action._target.browser); + assert.calledOnce(global.NewTabUtils.activityStreamLinks.addPocketEntry); + assert.calledWithExactly( + global.NewTabUtils.activityStreamLinks.addPocketEntry, + action.data.site.url, + action.data.site.title, + action._target.browser + ); + }); + it("should reject the promise if NewTabUtils.activityStreamLinks.addPocketEntry rejects", async () => { + const e = new Error("Error"); + const action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox + .stub() + .rejects(e); + await feed.saveToPocket(action.data.site, action._target.browser); + assert.calledWith(global.console.error, e); + }); + it("should broadcast to content if we successfully added a link to Pocket", async () => { + // test in the form that the API returns data based on: https://getpocket.com/developer/docs/v3/add + global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox + .stub() + .resolves({ item: { open_url: "pocket.com/itemID", item_id: 1234 } }); + const action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + await feed.saveToPocket(action.data.site, action._target.browser); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PLACES_SAVED_TO_POCKET + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "raspberry.com", + title: "raspberry", + pocket_id: 1234, + open_url: "pocket.com/itemID", + }); + }); + it("should only broadcast if we got some data back from addPocketEntry", async () => { + global.NewTabUtils.activityStreamLinks.addPocketEntry = sandbox + .stub() + .resolves(null); + const action = { + data: { site: { url: "raspberry.com", title: "raspberry" } }, + _target: { browser: {} }, + }; + await feed.saveToPocket(action.data.site, action._target.browser); + assert.notCalled(feed.store.dispatch); + }); + it("should call deleteFromPocket on DELETE_FROM_POCKET", () => { + sandbox.stub(feed, "deleteFromPocket"); + feed.onAction({ + type: at.DELETE_FROM_POCKET, + data: { pocket_id: 12345 }, + }); + + assert.calledOnce(feed.deleteFromPocket); + assert.calledWithExactly(feed.deleteFromPocket, 12345); + }); + it("should catch if deletePocketEntry throws", async () => { + const e = new Error("Error"); + global.NewTabUtils.activityStreamLinks.deletePocketEntry = sandbox + .stub() + .rejects(e); + await feed.deleteFromPocket(12345); + + assert.calledWith(global.console.error, e); + }); + it("should call NewTabUtils.deletePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when deleting from Pocket", async () => { + await feed.deleteFromPocket(12345); + + assert.calledOnce( + global.NewTabUtils.activityStreamLinks.deletePocketEntry + ); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.deletePocketEntry, + 12345 + ); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + }); + }); + it("should call archiveFromPocket on ARCHIVE_FROM_POCKET", async () => { + sandbox.stub(feed, "archiveFromPocket"); + await feed.onAction({ + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: 12345 }, + }); + + assert.calledOnce(feed.archiveFromPocket); + assert.calledWithExactly(feed.archiveFromPocket, 12345); + }); + it("should catch if archiveFromPocket throws", async () => { + const e = new Error("Error"); + global.NewTabUtils.activityStreamLinks.archivePocketEntry = sandbox + .stub() + .rejects(e); + await feed.archiveFromPocket(12345); + + assert.calledWith(global.console.error, e); + }); + it("should call NewTabUtils.archivePocketEntry and dispatch POCKET_LINK_DELETED_OR_ARCHIVED when archiving from Pocket", async () => { + await feed.archiveFromPocket(12345); + + assert.calledOnce( + global.NewTabUtils.activityStreamLinks.archivePocketEntry + ); + assert.calledWith( + global.NewTabUtils.activityStreamLinks.archivePocketEntry, + 12345 + ); + + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + type: at.POCKET_LINK_DELETED_OR_ARCHIVED, + }); + }); + it("should call handoffSearchToAwesomebar on HANDOFF_SEARCH_TO_AWESOMEBAR", () => { + const action = { + type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, + data: { text: "f" }, + meta: { fromTarget: {} }, + _target: { browser: { ownerGlobal: { gURLBar: { focus: () => {} } } } }, + }; + sinon.stub(feed, "handoffSearchToAwesomebar"); + feed.onAction(action); + assert.calledWith(feed.handoffSearchToAwesomebar, action); + }); + it("should call makeAttributionRequest on PARTNER_LINK_ATTRIBUTION", () => { + sinon.stub(feed, "makeAttributionRequest"); + let data = { targetURL: "https://partnersite.com", source: "topsites" }; + feed.onAction({ + type: at.PARTNER_LINK_ATTRIBUTION, + data, + }); + + assert.calledOnce(feed.makeAttributionRequest); + assert.calledWithExactly(feed.makeAttributionRequest, data); + }); + it("should call PartnerLinkAttribution.makeRequest when calling makeAttributionRequest", () => { + let data = { targetURL: "https://partnersite.com", source: "topsites" }; + feed.makeAttributionRequest(data); + assert.calledOnce(global.PartnerLinkAttribution.makeRequest); + }); + }); + + describe("handoffSearchToAwesomebar", () => { + let fakeUrlBar; + let listeners; + + beforeEach(() => { + fakeUrlBar = { + focus: sinon.spy(), + handoff: sinon.spy(), + setHiddenFocus: sinon.spy(), + removeHiddenFocus: sinon.spy(), + addEventListener: (ev, cb) => { + listeners[ev] = cb; + }, + removeEventListener: sinon.spy(), + }; + listeners = {}; + }); + it("should properly handle handoff with no text passed in", () => { + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } }, + data: {}, + meta: { fromTarget: {} }, + }); + assert.calledOnce(fakeUrlBar.setHiddenFocus); + assert.notCalled(fakeUrlBar.handoff); + assert.notCalled(feed.store.dispatch); + + // Now type a character. + listeners.keydown({ key: "f" }); + assert.calledOnce(fakeUrlBar.handoff); + assert.calledOnce(fakeUrlBar.removeHiddenFocus); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "DISABLE_SEARCH", + }); + }); + it("should properly handle handoff with text data passed in", () => { + const sessionId = "decafc0ffee"; + sandbox + .stub(global.AboutNewTab.activityStream.store.feeds, "get") + .returns({ + sessions: { + get: () => { + return { session_id: sessionId }; + }, + }, + }); + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + assert.calledOnce(fakeUrlBar.handoff); + assert.calledWithExactly( + fakeUrlBar.handoff, + "foo", + global.Services.search.defaultEngine, + sessionId + ); + assert.notCalled(fakeUrlBar.focus); + assert.notCalled(fakeUrlBar.setHiddenFocus); + + // Now call blur listener. + listeners.blur(); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }); + }); + it("should properly handle handoff with text data passed in, in private browsing mode", () => { + global.PrivateBrowsingUtils.isBrowserPrivate = () => true; + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + assert.calledOnce(fakeUrlBar.handoff); + assert.calledWithExactly( + fakeUrlBar.handoff, + "foo", + global.Services.search.defaultPrivateEngine, + undefined + ); + assert.notCalled(fakeUrlBar.focus); + assert.notCalled(fakeUrlBar.setHiddenFocus); + + // Now call blur listener. + listeners.blur(); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }); + global.PrivateBrowsingUtils.isBrowserPrivate = () => false; + }); + it("should SHOW_SEARCH on ESC keydown", () => { + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } }, + data: { text: "foo" }, + meta: { fromTarget: {} }, + }); + assert.calledOnce(fakeUrlBar.handoff); + assert.calledWithExactly( + fakeUrlBar.handoff, + "foo", + global.Services.search.defaultEngine, + undefined + ); + assert.notCalled(fakeUrlBar.focus); + + // Now call ESC keydown. + listeners.keydown({ key: "Escape" }); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "SHOW_SEARCH", + }); + }); + it("should properly handoff a newtab session id with no text passed in", () => { + const sessionId = "decafc0ffee"; + sandbox + .stub(global.AboutNewTab.activityStream.store.feeds, "get") + .returns({ + sessions: { + get: () => { + return { session_id: sessionId }; + }, + }, + }); + feed.handoffSearchToAwesomebar({ + _target: { browser: { ownerGlobal: { gURLBar: fakeUrlBar } } }, + data: {}, + meta: { fromTarget: {} }, + }); + assert.calledOnce(fakeUrlBar.setHiddenFocus); + assert.notCalled(fakeUrlBar.handoff); + assert.notCalled(feed.store.dispatch); + + // Now type a character. + listeners.keydown({ key: "f" }); + assert.calledOnce(fakeUrlBar.handoff); + assert.calledWithExactly( + fakeUrlBar.handoff, + "", + global.Services.search.defaultEngine, + sessionId + ); + assert.calledOnce(fakeUrlBar.removeHiddenFocus); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + meta: { + from: "ActivityStream:Main", + skipMain: true, + to: "ActivityStream:Content", + toTarget: {}, + }, + type: "DISABLE_SEARCH", + }); + }); + }); + + describe("#observe", () => { + it("should dispatch a PLACES_LINK_BLOCKED action with the url of the blocked link", () => { + feed.observe(null, BLOCKED_EVENT, "foo123.com"); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PLACES_LINK_BLOCKED + ); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + url: "foo123.com", + }); + }); + it("should not call dispatch if the topic is something other than BLOCKED_EVENT", () => { + feed.observe(null, "someotherevent"); + assert.notCalled(feed.store.dispatch); + }); + }); + + describe("Custom dispatch", () => { + it("should only dispatch 1 PLACES_LINKS_CHANGED action if many bookmark-added notifications happened at once", async () => { + // Yes, onItemAdded has at least 8 arguments. See function definition for docs. + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + assert.calledOnce( + feed.store.dispatch.withArgs( + ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED }) + ) + ); + }); + it("should only dispatch 1 PLACES_LINKS_CHANGED action if many onItemRemoved notifications happened at once", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + ]; + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + await feed.placesObserver.handlePlacesEvent(args); + + assert.calledOnce( + feed.store.dispatch.withArgs( + ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED }) + ) + ); + }); + it("should only dispatch 1 PLACES_LINKS_CHANGED action if any page-removed notifications happened at once", async () => { + await feed.placesObserver.handlePlacesEvent([ + { type: "page-removed", url: "foo.com", isRemovedFromStore: true }, + ]); + await feed.placesObserver.handlePlacesEvent([ + { type: "page-removed", url: "foo1.com", isRemovedFromStore: true }, + ]); + await feed.placesObserver.handlePlacesEvent([ + { type: "page-removed", url: "foo2.com", isRemovedFromStore: true }, + ]); + + assert.calledOnce( + feed.store.dispatch.withArgs( + ac.OnlyToMain({ type: at.PLACES_LINKS_CHANGED }) + ) + ); + }); + }); + + describe("PlacesObserver", () => { + let dispatch; + let observer; + beforeEach(() => { + dispatch = sandbox.spy(); + observer = new PlacesObserver(dispatch); + }); + + describe("#history-cleared", () => { + it("should dispatch a PLACES_HISTORY_CLEARED action", async () => { + const args = [{ type: "history-cleared" }]; + await observer.handlePlacesEvent(args); + assert.calledWith(dispatch, { type: at.PLACES_HISTORY_CLEARED }); + }); + }); + + describe("#page-removed", () => { + it("should dispatch a PLACES_LINKS_DELETED action with the right url", async () => { + const args = [ + { + type: "page-removed", + url: "foo.com", + isRemovedFromStore: true, + }, + ]; + await observer.handlePlacesEvent(args); + assert.calledWith(dispatch, { + type: at.PLACES_LINKS_DELETED, + data: { urls: ["foo.com"] }, + }); + }); + }); + + describe("#bookmark-added", () => { + it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - http", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "http://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.calledWith(dispatch.secondCall, { + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid, + bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle, + dateAdded: FAKE_BOOKMARK.dateAdded * 1000, + url: "http://www.foo.com", + }, + }); + }); + it("should dispatch a PLACES_BOOKMARK_ADDED action with the bookmark data - https", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.calledWith(dispatch.secondCall, { + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: FAKE_BOOKMARK.bookmarkGuid, + bookmarkTitle: FAKE_BOOKMARK.bookmarkTitle, + dateAdded: FAKE_BOOKMARK.dateAdded * 1000, + url: "https://www.foo.com", + }, + }); + }); + it("should not dispatch a PLACES_BOOKMARK_ADDED action - not http/https", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARK_ADDED action - has IMPORT source", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.IMPORT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE source", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.RESTORE, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARK_ADDED action - has RESTORE_ON_STARTUP source", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.RESTORE_ON_STARTUP, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARK_ADDED action - has SYNC source", async () => { + const args = [ + { + itemType: TYPE_BOOKMARK, + source: SOURCES.SYNC, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should ignore events that are not of TYPE_BOOKMARK", async () => { + const args = [ + { + itemType: "nottypebookmark", + source: SOURCES.DEFAULT, + dateAdded: FAKE_BOOKMARK.dateAdded, + guid: FAKE_BOOKMARK.bookmarkGuid, + title: FAKE_BOOKMARK.bookmarkTitle, + url: "https://www.foo.com", + isTagging: false, + type: "bookmark-added", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + }); + describe("#bookmark-removed", () => { + it("should ignore events that are not of TYPE_BOOKMARK", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: "nottypebookmark", + url: null, + guid: "123foo", + parentGuid: "", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has SYNC source", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.SYNC, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has IMPORT source", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.IMPORT, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE source", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.RESTORE, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should not dispatch a PLACES_BOOKMARKS_REMOVED action - has RESTORE_ON_STARTUP source", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.RESTORE_ON_STARTUP, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + + assert.notCalled(dispatch); + }); + it("should dispatch a PLACES_BOOKMARKS_REMOVED action with the right URL and bookmarkGuid", async () => { + const args = [ + { + id: null, + parentId: null, + index: null, + itemType: TYPE_BOOKMARK, + url: "foo.com", + guid: "123foo", + parentGuid: "", + source: SOURCES.DEFAULT, + type: "bookmark-removed", + }, + ]; + await observer.handlePlacesEvent(args); + assert.calledWith(dispatch, { + type: at.PLACES_BOOKMARKS_REMOVED, + data: { urls: ["foo.com"] }, + }); + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/PrefsFeed.test.js b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js new file mode 100644 index 0000000000..581222b3ee --- /dev/null +++ b/browser/components/newtab/test/unit/lib/PrefsFeed.test.js @@ -0,0 +1,357 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; +import { PrefsFeed } from "lib/PrefsFeed.jsm"; + +let overrider = new GlobalOverrider(); + +describe("PrefsFeed", () => { + let feed; + let FAKE_PREFS; + let sandbox; + let ServicesStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + FAKE_PREFS = new Map([ + ["foo", 1], + ["bar", 2], + ["baz", { value: 1, skipBroadcast: true }], + ["qux", { value: 1, skipBroadcast: true, alsoToPreloaded: true }], + ]); + feed = new PrefsFeed(FAKE_PREFS); + const storage = { + getAll: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + ServicesStub = { + prefs: { + clearUserPref: sinon.spy(), + getStringPref: sinon.spy(), + getIntPref: sinon.spy(), + getBoolPref: sinon.spy(), + }, + obs: { + removeObserver: sinon.spy(), + addObserver: sinon.spy(), + }, + }; + sinon.spy(feed, "_setPref"); + feed.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + dbStorage: { getDbTable: sandbox.stub().returns(storage) }, + }; + // Setup for tests that don't call `init` + feed._storage = storage; + feed._prefs = { + get: sinon.spy(item => FAKE_PREFS.get(item)), + set: sinon.spy((name, value) => FAKE_PREFS.set(name, value)), + observe: sinon.spy(), + observeBranch: sinon.spy(), + ignore: sinon.spy(), + ignoreBranch: sinon.spy(), + reset: sinon.stub(), + _branchStr: "branch.str.", + }; + overrider.set({ + PrivateBrowsingUtils: { enabled: true }, + Services: ServicesStub, + }); + }); + afterEach(() => { + overrider.restore(); + sandbox.restore(); + }); + + it("should set a pref when a SET_PREF action is received", () => { + feed.onAction(ac.SetPref("foo", 2)); + assert.calledWith(feed._prefs.set, "foo", 2); + }); + it("should call clearUserPref with action CLEAR_PREF", () => { + feed.onAction({ type: at.CLEAR_PREF, data: { name: "pref.test" } }); + assert.calledWith(ServicesStub.prefs.clearUserPref, "branch.str.pref.test"); + }); + it("should dispatch PREFS_INITIAL_VALUES on init with pref values and .isPrivateBrowsingEnabled", () => { + feed.onAction({ type: at.INIT }); + assert.calledOnce(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PREFS_INITIAL_VALUES + ); + const [{ data }] = feed.store.dispatch.firstCall.args; + assert.equal(data.foo, 1); + assert.equal(data.bar, 2); + assert.isTrue(data.isPrivateBrowsingEnabled); + }); + it("should dispatch PREFS_INITIAL_VALUES with a .featureConfig", () => { + sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({ + prefsButtonIcon: "icon-foo", + }); + feed.onAction({ type: at.INIT }); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PREFS_INITIAL_VALUES + ); + const [{ data }] = feed.store.dispatch.firstCall.args; + assert.deepEqual(data.featureConfig, { prefsButtonIcon: "icon-foo" }); + }); + it("should dispatch PREFS_INITIAL_VALUES with an empty object if no experiment is returned", () => { + sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns(null); + feed.onAction({ type: at.INIT }); + assert.equal( + feed.store.dispatch.firstCall.args[0].type, + at.PREFS_INITIAL_VALUES + ); + const [{ data }] = feed.store.dispatch.firstCall.args; + assert.deepEqual(data.featureConfig, {}); + }); + it("should add one branch observer on init", () => { + feed.onAction({ type: at.INIT }); + assert.calledOnce(feed._prefs.observeBranch); + assert.calledWith(feed._prefs.observeBranch, feed); + }); + it("should initialise the storage on init", () => { + feed.init(); + + assert.calledOnce(feed.store.dbStorage.getDbTable); + assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs"); + }); + it("should handle region on init", () => { + feed.init(); + assert.equal(feed.geo, "US"); + }); + it("should add region observer on init", () => { + sandbox.stub(global.Region, "home").get(() => ""); + feed.init(); + assert.equal(feed.geo, ""); + assert.calledWith( + ServicesStub.obs.addObserver, + feed, + global.Region.REGION_TOPIC + ); + }); + it("should remove the branch observer on uninit", () => { + feed.onAction({ type: at.UNINIT }); + assert.calledOnce(feed._prefs.ignoreBranch); + assert.calledWith(feed._prefs.ignoreBranch, feed); + }); + it("should call removeObserver", () => { + feed.geo = ""; + feed.uninit(); + assert.calledWith( + ServicesStub.obs.removeObserver, + feed, + global.Region.REGION_TOPIC + ); + }); + it("should send a PREF_CHANGED action when onPrefChanged is called", () => { + feed.onPrefChanged("foo", 2); + assert.calledWith( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { name: "foo", value: 2 }, + }) + ); + }); + it("should send a PREF_CHANGED actions when onPocketExperimentUpdated is called", () => { + sandbox + .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables") + .returns({ + prefsButtonIcon: "icon-new", + }); + feed.onPocketExperimentUpdated(); + assert.calledWith( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { + name: "pocketConfig", + value: { + prefsButtonIcon: "icon-new", + }, + }, + }) + ); + }); + it("should not send a PREF_CHANGED actions when onPocketExperimentUpdated is called during startup", () => { + sandbox + .stub(global.NimbusFeatures.pocketNewtab, "getAllVariables") + .returns({ + prefsButtonIcon: "icon-new", + }); + feed.onPocketExperimentUpdated({}, "feature-experiment-loaded"); + assert.notCalled(feed.store.dispatch); + feed.onPocketExperimentUpdated({}, "feature-rollout-loaded"); + assert.notCalled(feed.store.dispatch); + }); + it("should send a PREF_CHANGED actions when onExperimentUpdated is called", () => { + sandbox.stub(global.NimbusFeatures.newtab, "getAllVariables").returns({ + prefsButtonIcon: "icon-new", + }); + feed.onExperimentUpdated(); + assert.calledWith( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.PREF_CHANGED, + data: { + name: "featureConfig", + value: { + prefsButtonIcon: "icon-new", + }, + }, + }) + ); + }); + + it("should remove all events on removeListeners", () => { + feed.geo = ""; + sandbox.spy(global.NimbusFeatures.pocketNewtab, "offUpdate"); + sandbox.spy(global.NimbusFeatures.newtab, "offUpdate"); + feed.removeListeners(); + assert.calledWith( + global.NimbusFeatures.pocketNewtab.offUpdate, + feed.onPocketExperimentUpdated + ); + assert.calledWith( + global.NimbusFeatures.newtab.offUpdate, + feed.onExperimentUpdated + ); + assert.calledWith( + ServicesStub.obs.removeObserver, + feed, + global.Region.REGION_TOPIC + ); + }); + + it("should set storage pref on UPDATE_SECTION_PREFS", async () => { + await feed.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topsites", value: { collapsed: false } }, + }); + assert.calledWith(feed._storage.set, "topsites", { collapsed: false }); + }); + it("should set storage pref with section prefix on UPDATE_SECTION_PREFS", async () => { + await feed.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topstories", value: { collapsed: false } }, + }); + assert.calledWith(feed._storage.set, "feeds.section.topstories", { + collapsed: false, + }); + }); + it("should catch errors on UPDATE_SECTION_PREFS", async () => { + feed._storage.set.throws(new Error("foo")); + assert.doesNotThrow(async () => { + await feed.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topstories", value: { collapsed: false } }, + }); + }); + }); + it("should send OnlyToMain pref update if config for pref has skipBroadcast: true", async () => { + feed.onPrefChanged("baz", { value: 2, skipBroadcast: true }); + assert.calledWith( + feed.store.dispatch, + ac.OnlyToMain({ + type: at.PREF_CHANGED, + data: { name: "baz", value: { value: 2, skipBroadcast: true } }, + }) + ); + }); + it("should send AlsoToPreloaded pref update if config for pref has skipBroadcast: true and alsoToPreloaded: true", async () => { + feed.onPrefChanged("qux", { + value: 2, + skipBroadcast: true, + alsoToPreloaded: true, + }); + assert.calledWith( + feed.store.dispatch, + ac.AlsoToPreloaded({ + type: at.PREF_CHANGED, + data: { + name: "qux", + value: { value: 2, skipBroadcast: true, alsoToPreloaded: true }, + }, + }) + ); + }); + describe("#observe", () => { + it("should call dispatch from observe", () => { + feed.observe(undefined, global.Region.REGION_TOPIC); + assert.calledOnce(feed.store.dispatch); + }); + }); + describe("#_setStringPref", () => { + it("should call _setPref and getStringPref from _setStringPref", () => { + feed._setStringPref({}, "fake.pref", "default"); + assert.calledOnce(feed._setPref); + assert.calledWith( + feed._setPref, + { "fake.pref": undefined }, + "fake.pref", + "default" + ); + assert.calledOnce(ServicesStub.prefs.getStringPref); + assert.calledWith( + ServicesStub.prefs.getStringPref, + "browser.newtabpage.activity-stream.fake.pref", + "default" + ); + }); + }); + describe("#_setBoolPref", () => { + it("should call _setPref and getBoolPref from _setBoolPref", () => { + feed._setBoolPref({}, "fake.pref", false); + assert.calledOnce(feed._setPref); + assert.calledWith( + feed._setPref, + { "fake.pref": undefined }, + "fake.pref", + false + ); + assert.calledOnce(ServicesStub.prefs.getBoolPref); + assert.calledWith( + ServicesStub.prefs.getBoolPref, + "browser.newtabpage.activity-stream.fake.pref", + false + ); + }); + }); + describe("#_setIntPref", () => { + it("should call _setPref and getIntPref from _setIntPref", () => { + feed._setIntPref({}, "fake.pref", 1); + assert.calledOnce(feed._setPref); + assert.calledWith( + feed._setPref, + { "fake.pref": undefined }, + "fake.pref", + 1 + ); + assert.calledOnce(ServicesStub.prefs.getIntPref); + assert.calledWith( + ServicesStub.prefs.getIntPref, + "browser.newtabpage.activity-stream.fake.pref", + 1 + ); + }); + }); + describe("#_setPref", () => { + it("should set pref value with _setPref", () => { + const getPrefFunctionSpy = sinon.spy(); + const values = {}; + feed._setPref(values, "fake.pref", "default", getPrefFunctionSpy); + assert.deepEqual(values, { "fake.pref": undefined }); + assert.calledOnce(getPrefFunctionSpy); + assert.calledWith( + getPrefFunctionSpy, + "browser.newtabpage.activity-stream.fake.pref", + "default" + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js new file mode 100644 index 0000000000..3ddbf182c3 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/RecommendationProvider.test.js @@ -0,0 +1,162 @@ +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { RecommendationProvider } from "lib/RecommendationProvider.jsm"; +import { combineReducers, createStore } from "redux"; +import { reducers } from "common/Reducers.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +import { PersonalityProvider } from "lib/PersonalityProvider/PersonalityProvider.jsm"; + +const PREF_PERSONALIZATION_ENABLED = "discoverystream.personalization.enabled"; +const PREF_PERSONALIZATION_MODEL_KEYS = + "discoverystream.personalization.modelKeys"; +describe("RecommendationProvider", () => { + let feed; + let sandbox; + let globals; + + beforeEach(() => { + globals = new GlobalOverrider(); + globals.set({ + PersonalityProvider, + }); + + sandbox = sinon.createSandbox(); + feed = new RecommendationProvider(); + feed.store = createStore(combineReducers(reducers), {}); + }); + + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + + describe("#setProvider", () => { + it("should setup proper provider with modelKeys", async () => { + feed.setProvider(); + + assert.equal(feed.provider.modelKeys, undefined); + + feed.provider = null; + feed._modelKeys = "1234"; + + feed.setProvider(); + + assert.equal(feed.provider.modelKeys, "1234"); + feed._modelKeys = "12345"; + + // Calling it again should not rebuild the provider. + feed.setProvider(); + assert.equal(feed.provider.modelKeys, "1234"); + }); + }); + + describe("#init", () => { + it("should init affinityProvider then refreshContent", async () => { + feed.provider = { + init: sandbox.stub().resolves(), + }; + await feed.init(); + assert.calledOnce(feed.provider.init); + }); + }); + + describe("#getScores", () => { + it("should call affinityProvider.getScores", () => { + feed.provider = { + getScores: sandbox.stub().resolves(), + }; + feed.getScores(); + assert.calledOnce(feed.provider.getScores); + }); + }); + + describe("#calculateItemRelevanceScore", () => { + it("should use personalized score with provider", async () => { + const item = {}; + feed.provider = { + calculateItemRelevanceScore: async () => 0.5, + }; + await feed.calculateItemRelevanceScore(item); + assert.equal(item.score, 0.5); + }); + }); + + describe("#teardown", () => { + it("should call provider.teardown ", () => { + feed.provider = { + teardown: sandbox.stub().resolves(), + }; + feed.teardown(); + assert.calledOnce(feed.provider.teardown); + }); + }); + + describe("#resetState", () => { + it("should null affinityProviderV2 and affinityProvider", () => { + feed._modelKeys = {}; + feed.provider = {}; + + feed.resetState(); + + assert.equal(feed._modelKeys, null); + assert.equal(feed.provider, null); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => { + it("should call teardown, resetState, and setVersion", async () => { + sandbox.spy(feed, "teardown"); + sandbox.spy(feed, "resetState"); + feed.onAction({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + }); + assert.calledOnce(feed.teardown); + assert.calledOnce(feed.resetState); + }); + }); + + describe("#onAction: PREF_CHANGED", () => { + beforeEach(() => { + sandbox.spy(feed.store, "dispatch"); + }); + it("should dispatch to DISCOVERY_STREAM_CONFIG_RESET PREF_PERSONALIZATION_MODEL_KEYS", async () => { + feed.onAction({ + type: at.PREF_CHANGED, + data: { + name: PREF_PERSONALIZATION_MODEL_KEYS, + }, + }); + + assert.calledWith( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_CONFIG_RESET, + }) + ); + }); + }); + + describe("#onAction: DISCOVERY_STREAM_PERSONALIZATION_TOGGLE", () => { + it("should fire SET_PREF with enabled", async () => { + sandbox.spy(feed.store, "dispatch"); + feed.store.getState = () => ({ + Prefs: { + values: { + [PREF_PERSONALIZATION_ENABLED]: false, + }, + }, + }); + + await feed.onAction({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }); + assert.calledWith( + feed.store.dispatch, + ac.SetPref(PREF_PERSONALIZATION_ENABLED, true) + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/Screenshots.test.js b/browser/components/newtab/test/unit/lib/Screenshots.test.js new file mode 100644 index 0000000000..272c7ff7d3 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/Screenshots.test.js @@ -0,0 +1,209 @@ +"use strict"; +import { GlobalOverrider } from "test/unit/utils"; +import { Screenshots } from "lib/Screenshots.jsm"; + +const URL = "foo.com"; +const FAKE_THUMBNAIL_PATH = "fake/path/thumb.jpg"; +const FAKE_THUMBNAIL_THUMB = + "moz-page-thumb://thumbnail?url=http%3A%2F%2Ffoo.com%2F"; + +describe("Screenshots", () => { + let globals; + let sandbox; + let fakeServices; + let testFile; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + fakeServices = { + wm: { + getEnumerator() { + return Array(10); + }, + }, + }; + globals.set("BackgroundPageThumbs", { + captureIfMissing: sandbox.spy(() => Promise.resolve()), + }); + globals.set("PageThumbs", { + _store: sandbox.stub(), + getThumbnailPath: sandbox.spy(() => FAKE_THUMBNAIL_PATH), + getThumbnailURL: sandbox.spy(() => FAKE_THUMBNAIL_THUMB), + }); + globals.set("PrivateBrowsingUtils", { + isWindowPrivate: sandbox.spy(() => false), + }); + testFile = { size: 1 }; + globals.set("Services", fakeServices); + globals.set( + "fetch", + sandbox.spy(() => + Promise.resolve({ blob: () => Promise.resolve(testFile) }) + ) + ); + }); + afterEach(() => { + globals.restore(); + }); + + describe("#getScreenshotForURL", () => { + it("should call BackgroundPageThumbs.captureIfMissing with the correct url", async () => { + await Screenshots.getScreenshotForURL(URL); + assert.calledWith(global.BackgroundPageThumbs.captureIfMissing, URL); + }); + it("should call PageThumbs.getThumbnailPath with the correct url", async () => { + globals.set("gPrivilegedAboutProcessEnabled", false); + await Screenshots.getScreenshotForURL(URL); + assert.calledWith(global.PageThumbs.getThumbnailPath, URL); + }); + it("should call fetch", async () => { + await Screenshots.getScreenshotForURL(URL); + assert.calledOnce(global.fetch); + }); + it("should have the necessary keys in the response object", async () => { + const screenshot = await Screenshots.getScreenshotForURL(URL); + + assert.notEqual(screenshot.path, undefined); + assert.notEqual(screenshot.data, undefined); + }); + it("should get null if something goes wrong", async () => { + globals.set("BackgroundPageThumbs", { + captureIfMissing: () => + Promise.reject(new Error("Cannot capture thumbnail")), + }); + + const screenshot = await Screenshots.getScreenshotForURL(URL); + + assert.calledOnce(global.PageThumbs._store); + assert.equal(screenshot, null); + }); + it("should get direct thumbnail url for privileged process", async () => { + globals.set("gPrivilegedAboutProcessEnabled", true); + await Screenshots.getScreenshotForURL(URL); + assert.calledWith(global.PageThumbs.getThumbnailURL, URL); + }); + it("should get null without storing if existing thumbnail is empty", async () => { + testFile.size = 0; + + const screenshot = await Screenshots.getScreenshotForURL(URL); + + assert.notCalled(global.PageThumbs._store); + assert.equal(screenshot, null); + }); + }); + + describe("#maybeCacheScreenshot", () => { + let link; + beforeEach(() => { + link = { + __sharedCache: { + updateLink: (prop, val) => { + link[prop] = val; + }, + }, + }; + }); + it("should call getScreenshotForURL", () => { + sandbox.stub(Screenshots, "getScreenshotForURL"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + Screenshots.maybeCacheScreenshot( + link, + "mozilla.com", + "image", + sinon.stub() + ); + + assert.calledOnce(Screenshots.getScreenshotForURL); + assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com"); + }); + it("should not call getScreenshotForURL twice if a fetch is in progress", () => { + sandbox + .stub(Screenshots, "getScreenshotForURL") + .returns(new Promise(() => {})); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + Screenshots.maybeCacheScreenshot( + link, + "mozilla.com", + "image", + sinon.stub() + ); + Screenshots.maybeCacheScreenshot( + link, + "mozilla.org", + "image", + sinon.stub() + ); + + assert.calledOnce(Screenshots.getScreenshotForURL); + assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com"); + }); + it("should not call getScreenshotsForURL if property !== undefined", async () => { + sandbox + .stub(Screenshots, "getScreenshotForURL") + .returns(Promise.resolve(null)); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + await Screenshots.maybeCacheScreenshot( + link, + "mozilla.com", + "image", + sinon.stub() + ); + await Screenshots.maybeCacheScreenshot( + link, + "mozilla.org", + "image", + sinon.stub() + ); + + assert.calledOnce(Screenshots.getScreenshotForURL); + assert.calledWithExactly(Screenshots.getScreenshotForURL, "mozilla.com"); + }); + it("should check if we are in private browsing before getting screenshots", async () => { + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(true); + await Screenshots.maybeCacheScreenshot( + link, + "mozilla.com", + "image", + sinon.stub() + ); + + assert.calledOnce(Screenshots._shouldGetScreenshots); + }); + it("should not get a screenshot if we are in private browsing", async () => { + sandbox.stub(Screenshots, "getScreenshotForURL"); + sandbox.stub(Screenshots, "_shouldGetScreenshots").returns(false); + await Screenshots.maybeCacheScreenshot( + link, + "mozilla.com", + "image", + sinon.stub() + ); + + assert.notCalled(Screenshots.getScreenshotForURL); + }); + }); + + describe("#_shouldGetScreenshots", () => { + beforeEach(() => { + let more = 2; + sandbox + .stub(global.Services.wm, "getEnumerator") + .callsFake(() => Array(Math.max(more--, 0))); + }); + it("should use private browsing utils to determine if a window is private", () => { + Screenshots._shouldGetScreenshots(); + assert.calledOnce(global.PrivateBrowsingUtils.isWindowPrivate); + }); + it("should return true if there exists at least 1 non-private window", () => { + assert.isTrue(Screenshots._shouldGetScreenshots()); + }); + it("should return false if there exists private windows", () => { + global.PrivateBrowsingUtils = { + isWindowPrivate: sandbox.spy(() => true), + }; + assert.isFalse(Screenshots._shouldGetScreenshots()); + assert.calledTwice(global.PrivateBrowsingUtils.isWindowPrivate); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/SectionsManager.test.js b/browser/components/newtab/test/unit/lib/SectionsManager.test.js new file mode 100644 index 0000000000..dc0be33180 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/SectionsManager.test.js @@ -0,0 +1,897 @@ +"use strict"; +import { + actionCreators as ac, + actionTypes as at, + CONTENT_MESSAGE_TYPE, + MAIN_MESSAGE_TYPE, + PRELOAD_MESSAGE_TYPE, +} from "common/Actions.sys.mjs"; +import { EventEmitter, GlobalOverrider } from "test/unit/utils"; +import { SectionsFeed, SectionsManager } from "lib/SectionsManager.jsm"; + +const FAKE_ID = "FAKE_ID"; +const FAKE_OPTIONS = { icon: "FAKE_ICON", title: "FAKE_TITLE" }; +const FAKE_ROWS = [ + { url: "1.example.com", type: "bookmark" }, + { url: "2.example.com", type: "pocket" }, + { url: "3.example.com", type: "history" }, +]; +const FAKE_TRENDING_ROWS = [{ url: "bar", type: "trending" }]; +const FAKE_URL = "2.example.com"; +const FAKE_CARD_OPTIONS = { title: "Some fake title" }; + +describe("SectionsManager", () => { + let globals; + let fakeServices; + let fakePlacesUtils; + let sandbox; + let storage; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + fakeServices = { + prefs: { + getBoolPref: sandbox.stub(), + addObserver: sandbox.stub(), + removeObserver: sandbox.stub(), + }, + }; + fakePlacesUtils = { + history: { update: sinon.stub(), insert: sinon.stub() }, + }; + globals.set({ + Services: fakeServices, + PlacesUtils: fakePlacesUtils, + NimbusFeatures: { + newtab: { getAllVariables: sandbox.stub() }, + pocketNewtab: { getAllVariables: sandbox.stub() }, + }, + }); + // Redecorate SectionsManager to remove any listeners that have been added + EventEmitter.decorate(SectionsManager); + storage = { + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + }); + + afterEach(() => { + globals.restore(); + sandbox.restore(); + }); + + describe("#init", () => { + it("should initialise the sections map with the built in sections", async () => { + SectionsManager.sections.clear(); + SectionsManager.initialized = false; + await SectionsManager.init({}, storage); + assert.equal(SectionsManager.sections.size, 2); + assert.ok(SectionsManager.sections.has("topstories")); + assert.ok(SectionsManager.sections.has("highlights")); + }); + it("should set .initialized to true", async () => { + SectionsManager.sections.clear(); + SectionsManager.initialized = false; + await SectionsManager.init({}, storage); + assert.ok(SectionsManager.initialized); + }); + it("should add observer for context menu prefs", async () => { + SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" }; + await SectionsManager.init({}, storage); + assert.calledOnce(fakeServices.prefs.addObserver); + assert.calledWith( + fakeServices.prefs.addObserver, + "MENU_ITEM_PREF", + SectionsManager + ); + }); + it("should save the reference to `storage` passed in", async () => { + await SectionsManager.init({}, storage); + + assert.equal(SectionsManager._storage, storage); + }); + }); + describe("#uninit", () => { + it("should remove observer for context menu prefs", () => { + SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" }; + SectionsManager.initialized = true; + SectionsManager.uninit(); + assert.calledOnce(fakeServices.prefs.removeObserver); + assert.calledWith( + fakeServices.prefs.removeObserver, + "MENU_ITEM_PREF", + SectionsManager + ); + assert.isFalse(SectionsManager.initialized); + }); + }); + describe("#addBuiltInSection", () => { + it("should not report an error if options is undefined", async () => { + globals.sandbox.spy(global.console, "error"); + SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve()); + await SectionsManager.addBuiltInSection( + "feeds.section.topstories", + undefined + ); + + assert.notCalled(console.error); + }); + it("should report an error if options is malformed", async () => { + globals.sandbox.spy(global.console, "error"); + SectionsManager._storage.get = sandbox.stub().returns(Promise.resolve()); + await SectionsManager.addBuiltInSection( + "feeds.section.topstories", + "invalid" + ); + + assert.calledOnce(console.error); + }); + it("should not throw if the indexedDB operation fails", async () => { + globals.sandbox.spy(global.console, "error"); + storage.get = sandbox.stub().throws(); + SectionsManager._storage = storage; + + try { + await SectionsManager.addBuiltInSection("feeds.section.topstories"); + } catch (e) { + assert.fail(); + } + + assert.calledOnce(storage.get); + assert.calledOnce(console.error); + }); + }); + describe("#updateSectionPrefs", () => { + it("should update the collapsed value of the section", async () => { + sandbox.stub(SectionsManager, "updateSection"); + let topstories = SectionsManager.sections.get("topstories"); + assert.isFalse(topstories.pref.collapsed); + + await SectionsManager.updateSectionPrefs("topstories", { + collapsed: true, + }); + topstories = SectionsManager.sections.get("topstories"); + + assert.isTrue(SectionsManager.updateSection.args[0][1].pref.collapsed); + }); + it("should ignore invalid ids", async () => { + sandbox.stub(SectionsManager, "updateSection"); + await SectionsManager.updateSectionPrefs("foo", { collapsed: true }); + + assert.notCalled(SectionsManager.updateSection); + }); + }); + describe("#addSection", () => { + it("should add the id to sections and emit an ADD_SECTION event", () => { + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.ADD_SECTION, spy); + SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); + assert.ok(SectionsManager.sections.has(FAKE_ID)); + assert.calledOnce(spy); + assert.calledWith( + spy, + SectionsManager.ADD_SECTION, + FAKE_ID, + FAKE_OPTIONS + ); + }); + }); + describe("#removeSection", () => { + it("should remove the id from sections and emit an REMOVE_SECTION event", () => { + // Ensure we start with the id in the set + assert.ok(SectionsManager.sections.has(FAKE_ID)); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.REMOVE_SECTION, spy); + SectionsManager.removeSection(FAKE_ID); + assert.notOk(SectionsManager.sections.has(FAKE_ID)); + assert.calledOnce(spy); + assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID); + }); + }); + describe("#enableSection", () => { + it("should call updateSection with {enabled: true}", () => { + sinon.spy(SectionsManager, "updateSection"); + SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); + SectionsManager.enableSection(FAKE_ID); + assert.calledOnce(SectionsManager.updateSection); + assert.calledWith( + SectionsManager.updateSection, + FAKE_ID, + { enabled: true }, + true + ); + SectionsManager.updateSection.restore(); + }); + it("should emit an ENABLE_SECTION event", () => { + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.ENABLE_SECTION, spy); + SectionsManager.enableSection(FAKE_ID); + assert.calledOnce(spy); + assert.calledWith(spy, SectionsManager.ENABLE_SECTION, FAKE_ID); + }); + }); + describe("#disableSection", () => { + it("should call updateSection with {enabled: false, rows: [], initialized: false}", () => { + sinon.spy(SectionsManager, "updateSection"); + SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); + SectionsManager.disableSection(FAKE_ID); + assert.calledOnce(SectionsManager.updateSection); + assert.calledWith( + SectionsManager.updateSection, + FAKE_ID, + { enabled: false, rows: [], initialized: false }, + true + ); + SectionsManager.updateSection.restore(); + }); + it("should emit a DISABLE_SECTION event", () => { + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.DISABLE_SECTION, spy); + SectionsManager.disableSection(FAKE_ID); + assert.calledOnce(spy); + assert.calledWith(spy, SectionsManager.DISABLE_SECTION, FAKE_ID); + }); + }); + describe("#updateSection", () => { + it("should emit an UPDATE_SECTION event with correct arguments", () => { + SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); + const spy = sinon.spy(); + const dedupeConfigurations = [ + { id: "topstories", dedupeFrom: ["highlights"] }, + ]; + SectionsManager.on(SectionsManager.UPDATE_SECTION, spy); + SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true); + assert.calledOnce(spy); + assert.calledWith( + spy, + SectionsManager.UPDATE_SECTION, + FAKE_ID, + { rows: FAKE_ROWS, dedupeConfigurations }, + true + ); + }); + it("should do nothing if the section doesn't exist", () => { + SectionsManager.removeSection(FAKE_ID); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UPDATE_SECTION, spy); + SectionsManager.updateSection(FAKE_ID, { rows: FAKE_ROWS }, true); + assert.notCalled(spy); + }); + it("should update all sections", () => { + SectionsManager.sections.clear(); + const updateSectionOrig = SectionsManager.updateSection; + SectionsManager.updateSection = sinon.spy(); + + SectionsManager.addSection("ID1", { title: "FAKE_TITLE_1" }); + SectionsManager.addSection("ID2", { title: "FAKE_TITLE_2" }); + SectionsManager.updateSections(); + + assert.calledTwice(SectionsManager.updateSection); + assert.calledWith( + SectionsManager.updateSection, + "ID1", + { title: "FAKE_TITLE_1" }, + true + ); + assert.calledWith( + SectionsManager.updateSection, + "ID2", + { title: "FAKE_TITLE_2" }, + true + ); + SectionsManager.updateSection = updateSectionOrig; + }); + it("context menu pref change should update sections", async () => { + let observer; + const services = { + prefs: { + getBoolPref: sinon.spy(), + addObserver: (pref, o) => (observer = o), + removeObserver: sinon.spy(), + }, + }; + globals.set("Services", services); + + SectionsManager.updateSections = sinon.spy(); + SectionsManager.CONTEXT_MENU_PREFS = { MENU_ITEM: "MENU_ITEM_PREF" }; + await SectionsManager.init({}, storage); + observer.observe("", "nsPref:changed", "MENU_ITEM_PREF"); + + assert.calledOnce(SectionsManager.updateSections); + }); + }); + describe("#_addCardTypeLinkMenuOptions", () => { + const addCardTypeLinkMenuOptionsOrig = + SectionsManager._addCardTypeLinkMenuOptions; + const contextMenuOptionsOrig = + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES; + beforeEach(() => { + // Add a topstories section and a highlights section, with types for each card + SectionsManager.addSection("topstories", { FAKE_TRENDING_ROWS }); + SectionsManager.addSection("highlights", { FAKE_ROWS }); + }); + it("should only call _addCardTypeLinkMenuOptions if the section update is for highlights", () => { + SectionsManager._addCardTypeLinkMenuOptions = sinon.spy(); + SectionsManager.updateSection("topstories", { rows: FAKE_ROWS }, false); + assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions); + + SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false); + assert.calledWith(SectionsManager._addCardTypeLinkMenuOptions, FAKE_ROWS); + }); + it("should only call _addCardTypeLinkMenuOptions if the section update has rows", () => { + SectionsManager._addCardTypeLinkMenuOptions = sinon.spy(); + SectionsManager.updateSection("highlights", {}, false); + assert.notCalled(SectionsManager._addCardTypeLinkMenuOptions); + }); + it("should assign the correct context menu options based on the type of highlight", () => { + SectionsManager._addCardTypeLinkMenuOptions = + addCardTypeLinkMenuOptionsOrig; + + SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false); + const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS; + + // FAKE_ROWS was added in the following order: bookmark, pocket, history + assert.deepEqual( + highlights[0].contextMenuOptions, + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.bookmark + ); + assert.deepEqual( + highlights[1].contextMenuOptions, + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.pocket + ); + assert.deepEqual( + highlights[2].contextMenuOptions, + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES.history + ); + }); + it("should throw an error if you are assigning a context menu to a non-existant highlight type", () => { + globals.sandbox.spy(global.console, "error"); + SectionsManager.updateSection( + "highlights", + { rows: [{ url: "foo", type: "badtype" }] }, + false + ); + const highlights = SectionsManager.sections.get("highlights").rows; + assert.calledOnce(console.error); + assert.equal(highlights[0].contextMenuOptions, undefined); + }); + it("should filter out context menu options that are in CONTEXT_MENU_PREFS", () => { + const services = { + prefs: { + getBoolPref: o => + SectionsManager.CONTEXT_MENU_PREFS[o] !== "RemoveMe", + addObserver() {}, + removeObserver() {}, + }, + }; + globals.set("Services", services); + SectionsManager.CONTEXT_MENU_PREFS = { RemoveMe: "RemoveMe" }; + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES = { + bookmark: ["KeepMe", "RemoveMe"], + pocket: ["KeepMe", "RemoveMe"], + history: ["KeepMe", "RemoveMe"], + }; + SectionsManager.updateSection("highlights", { rows: FAKE_ROWS }, false); + const highlights = SectionsManager.sections.get("highlights").FAKE_ROWS; + + // Only keep context menu options that were not supposed to be removed based on CONTEXT_MENU_PREFS + assert.deepEqual(highlights[0].contextMenuOptions, ["KeepMe"]); + assert.deepEqual(highlights[1].contextMenuOptions, ["KeepMe"]); + assert.deepEqual(highlights[2].contextMenuOptions, ["KeepMe"]); + SectionsManager.CONTEXT_MENU_OPTIONS_FOR_HIGHLIGHT_TYPES = + contextMenuOptionsOrig; + globals.restore(); + }); + }); + describe("#onceInitialized", () => { + it("should call the callback immediately if SectionsManager is initialised", () => { + SectionsManager.initialized = true; + const callback = sinon.spy(); + SectionsManager.onceInitialized(callback); + assert.calledOnce(callback); + }); + it("should bind the callback to .once(INIT) if SectionsManager is not initialised", () => { + SectionsManager.initialized = false; + sinon.spy(SectionsManager, "once"); + const callback = () => {}; + SectionsManager.onceInitialized(callback); + assert.calledOnce(SectionsManager.once); + assert.calledWith(SectionsManager.once, SectionsManager.INIT, callback); + }); + }); + describe("#updateSectionCard", () => { + it("should emit an UPDATE_SECTION_CARD event with correct arguments", () => { + SectionsManager.addSection( + FAKE_ID, + Object.assign({}, FAKE_OPTIONS, { rows: FAKE_ROWS }) + ); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy); + SectionsManager.updateSectionCard( + FAKE_ID, + FAKE_URL, + FAKE_CARD_OPTIONS, + true + ); + assert.calledOnce(spy); + assert.calledWith( + spy, + SectionsManager.UPDATE_SECTION_CARD, + FAKE_ID, + FAKE_URL, + FAKE_CARD_OPTIONS, + true + ); + }); + it("should do nothing if the section doesn't exist", () => { + SectionsManager.removeSection(FAKE_ID); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UPDATE_SECTION_CARD, spy); + SectionsManager.updateSectionCard( + FAKE_ID, + FAKE_URL, + FAKE_CARD_OPTIONS, + true + ); + assert.notCalled(spy); + }); + }); + describe("#removeSectionCard", () => { + it("should dispatch an SECTION_UPDATE action in which cards corresponding to the given url are removed", () => { + const rows = [{ url: "foo.com" }, { url: "bar.com" }]; + + SectionsManager.addSection( + FAKE_ID, + Object.assign({}, FAKE_OPTIONS, { rows }) + ); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UPDATE_SECTION, spy); + SectionsManager.removeSectionCard(FAKE_ID, "foo.com"); + + assert.calledOnce(spy); + assert.equal(spy.firstCall.args[1], FAKE_ID); + assert.deepEqual(spy.firstCall.args[2].rows, [{ url: "bar.com" }]); + }); + it("should do nothing if the section doesn't exist", () => { + SectionsManager.removeSection(FAKE_ID); + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UPDATE_SECTION, spy); + SectionsManager.removeSectionCard(FAKE_ID, "bar.com"); + assert.notCalled(spy); + }); + }); + describe("#updateBookmarkMetadata", () => { + beforeEach(() => { + let rows = [ + { + url: "bar", + title: "title", + description: "description", + image: "image", + type: "trending", + }, + ]; + SectionsManager.addSection("topstories", { rows }); + // Simulate 2 sections. + rows = [ + { + url: "foo", + title: "title", + description: "description", + image: "image", + type: "bookmark", + }, + ]; + SectionsManager.addSection("highlights", { rows }); + }); + + it("shouldn't call PlacesUtils if URL is not in topstories", () => { + SectionsManager.updateBookmarkMetadata({ url: "foo" }); + + assert.notCalled(fakePlacesUtils.history.update); + }); + it("should call PlacesUtils.history.update", () => { + SectionsManager.updateBookmarkMetadata({ url: "bar" }); + + assert.calledOnce(fakePlacesUtils.history.update); + assert.calledWithExactly(fakePlacesUtils.history.update, { + url: "bar", + title: "title", + description: "description", + previewImageURL: "image", + }); + }); + it("should call PlacesUtils.history.insert", () => { + SectionsManager.updateBookmarkMetadata({ url: "bar" }); + + assert.calledOnce(fakePlacesUtils.history.insert); + assert.calledWithExactly(fakePlacesUtils.history.insert, { + url: "bar", + title: "title", + visits: [{}], + }); + }); + }); +}); + +describe("SectionsFeed", () => { + let feed; + let sandbox; + let storage; + let globals; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + SectionsManager.sections.clear(); + SectionsManager.initialized = false; + globals = new GlobalOverrider(); + globals.set("NimbusFeatures", { + newtab: { getAllVariables: sandbox.stub() }, + pocketNewtab: { getAllVariables: sandbox.stub() }, + }); + storage = { + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + feed = new SectionsFeed(); + feed.store = { dispatch: sinon.spy() }; + feed.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { + values: { + sectionOrder: "topsites,topstories,highlights", + "feeds.topsites": true, + }, + }, + Sections: [{ initialized: false }], + }, + dbStorage: { getDbTable: sandbox.stub().returns(storage) }, + }; + }); + afterEach(() => { + feed.uninit(); + globals.restore(); + }); + describe("#init", () => { + it("should create a SectionsFeed", () => { + assert.instanceOf(feed, SectionsFeed); + }); + it("should bind appropriate listeners", () => { + sinon.spy(SectionsManager, "on"); + feed.init(); + assert.callCount(SectionsManager.on, 4); + for (const [event, listener] of [ + [SectionsManager.ADD_SECTION, feed.onAddSection], + [SectionsManager.REMOVE_SECTION, feed.onRemoveSection], + [SectionsManager.UPDATE_SECTION, feed.onUpdateSection], + [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard], + ]) { + assert.calledWith(SectionsManager.on, event, listener); + } + }); + it("should call onAddSection for any already added sections in SectionsManager", async () => { + await SectionsManager.init({}, storage); + assert.ok(SectionsManager.sections.has("topstories")); + assert.ok(SectionsManager.sections.has("highlights")); + const topstories = SectionsManager.sections.get("topstories"); + const highlights = SectionsManager.sections.get("highlights"); + sinon.spy(feed, "onAddSection"); + feed.init(); + assert.calledTwice(feed.onAddSection); + assert.calledWith( + feed.onAddSection, + SectionsManager.ADD_SECTION, + "topstories", + topstories + ); + assert.calledWith( + feed.onAddSection, + SectionsManager.ADD_SECTION, + "highlights", + highlights + ); + }); + }); + describe("#uninit", () => { + it("should unbind all listeners", () => { + sinon.spy(SectionsManager, "off"); + feed.init(); + feed.uninit(); + assert.callCount(SectionsManager.off, 4); + for (const [event, listener] of [ + [SectionsManager.ADD_SECTION, feed.onAddSection], + [SectionsManager.REMOVE_SECTION, feed.onRemoveSection], + [SectionsManager.UPDATE_SECTION, feed.onUpdateSection], + [SectionsManager.UPDATE_SECTION_CARD, feed.onUpdateSectionCard], + ]) { + assert.calledWith(SectionsManager.off, event, listener); + } + }); + it("should emit an UNINIT event and set SectionsManager.initialized to false", () => { + const spy = sinon.spy(); + SectionsManager.on(SectionsManager.UNINIT, spy); + feed.init(); + feed.uninit(); + assert.calledOnce(spy); + assert.notOk(SectionsManager.initialized); + }); + }); + describe("#onAddSection", () => { + it("should broadcast a SECTION_REGISTER action with the correct data", () => { + feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS); + const [action] = feed.store.dispatch.firstCall.args; + assert.equal(action.type, "SECTION_REGISTER"); + assert.deepEqual( + action.data, + Object.assign({ id: FAKE_ID }, FAKE_OPTIONS) + ); + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE); + }); + it("should prepend id to sectionOrder pref if not already included", () => { + feed.store.state.Sections = [ + { id: "topstories", enabled: true }, + { id: "highlights", enabled: true }, + ]; + feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS); + assert.calledWith(feed.store.dispatch, { + data: { + name: "sectionOrder", + value: `${FAKE_ID},topsites,topstories,highlights`, + }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + }); + }); + describe("#onRemoveSection", () => { + it("should broadcast a SECTION_DEREGISTER action with the correct data", () => { + feed.onRemoveSection(null, FAKE_ID); + const [action] = feed.store.dispatch.firstCall.args; + assert.equal(action.type, "SECTION_DEREGISTER"); + assert.deepEqual(action.data, FAKE_ID); + // Should be broadcast + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE); + }); + }); + describe("#onUpdateSection", () => { + it("should do nothing if no options are provided", () => { + feed.onUpdateSection(null, FAKE_ID, null); + assert.notCalled(feed.store.dispatch); + }); + it("should dispatch a SECTION_UPDATE action with the correct data", () => { + feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS }); + const [action] = feed.store.dispatch.firstCall.args; + assert.equal(action.type, "SECTION_UPDATE"); + assert.deepEqual(action.data, { id: FAKE_ID, rows: FAKE_ROWS }); + // Should be not broadcast by default, but should update the preloaded tab, so check meta + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE); + }); + it("should broadcast the action only if shouldBroadcast is true", () => { + feed.onUpdateSection(null, FAKE_ID, { rows: FAKE_ROWS }, true); + const [action] = feed.store.dispatch.firstCall.args; + // Should be broadcast + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE); + }); + }); + describe("#onUpdateSectionCard", () => { + it("should do nothing if no options are provided", () => { + feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, null); + assert.notCalled(feed.store.dispatch); + }); + it("should dispatch a SECTION_UPDATE_CARD action with the correct data", () => { + feed.onUpdateSectionCard(null, FAKE_ID, FAKE_URL, FAKE_CARD_OPTIONS); + const [action] = feed.store.dispatch.firstCall.args; + assert.equal(action.type, "SECTION_UPDATE_CARD"); + assert.deepEqual(action.data, { + id: FAKE_ID, + url: FAKE_URL, + options: FAKE_CARD_OPTIONS, + }); + // Should be not broadcast by default, but should update the preloaded tab, so check meta + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, PRELOAD_MESSAGE_TYPE); + }); + it("should broadcast the action only if shouldBroadcast is true", () => { + feed.onUpdateSectionCard( + null, + FAKE_ID, + FAKE_URL, + FAKE_CARD_OPTIONS, + true + ); + const [action] = feed.store.dispatch.firstCall.args; + // Should be broadcast + assert.equal(action.meta.from, MAIN_MESSAGE_TYPE); + assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE); + }); + }); + describe("#onAction", () => { + it("should bind this.init to SectionsManager.INIT on INIT", () => { + sinon.spy(SectionsManager, "once"); + feed.onAction({ type: "INIT" }); + assert.calledOnce(SectionsManager.once); + assert.calledWith(SectionsManager.once, SectionsManager.INIT, feed.init); + }); + it("should call SectionsManager.init on action PREFS_INITIAL_VALUES", () => { + sinon.spy(SectionsManager, "init"); + feed.onAction({ type: "PREFS_INITIAL_VALUES", data: { foo: "bar" } }); + assert.calledOnce(SectionsManager.init); + assert.calledWith(SectionsManager.init, { foo: "bar" }); + assert.calledOnce(feed.store.dbStorage.getDbTable); + assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs"); + }); + it("should call SectionsManager.addBuiltInSection on suitable PREF_CHANGED events", () => { + sinon.spy(SectionsManager, "addBuiltInSection"); + feed.onAction({ + type: "PREF_CHANGED", + data: { name: "feeds.section.topstories.options", value: "foo" }, + }); + assert.calledOnce(SectionsManager.addBuiltInSection); + assert.calledWith( + SectionsManager.addBuiltInSection, + "feeds.section.topstories", + "foo" + ); + }); + it("should fire SECTION_OPTIONS_UPDATED on suitable PREF_CHANGED events", async () => { + await feed.onAction({ + type: "PREF_CHANGED", + data: { name: "feeds.section.topstories.options", value: "foo" }, + }); + assert.calledOnce(feed.store.dispatch); + const [action] = feed.store.dispatch.firstCall.args; + assert.equal(action.type, "SECTION_OPTIONS_CHANGED"); + assert.equal(action.data, "topstories"); + }); + it("should call SectionsManager.disableSection on SECTION_DISABLE", () => { + sinon.spy(SectionsManager, "disableSection"); + feed.onAction({ type: "SECTION_DISABLE", data: 1234 }); + assert.calledOnce(SectionsManager.disableSection); + assert.calledWith(SectionsManager.disableSection, 1234); + SectionsManager.disableSection.restore(); + }); + it("should call SectionsManager.enableSection on SECTION_ENABLE", () => { + sinon.spy(SectionsManager, "enableSection"); + feed.onAction({ type: "SECTION_ENABLE", data: 1234 }); + assert.calledOnce(SectionsManager.enableSection); + assert.calledWith(SectionsManager.enableSection, 1234); + SectionsManager.enableSection.restore(); + }); + it("should call the feed's uninit on UNINIT", () => { + sinon.stub(feed, "uninit"); + + feed.onAction({ type: "UNINIT" }); + + assert.calledOnce(feed.uninit); + }); + it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => { + const spy = sinon.spy(); + const allowedActions = SectionsManager.ACTIONS_TO_PROXY; + const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"]; + feed.init(); + SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy); + // Make sure we start with no sections - no event should be emitted + SectionsManager.sections.clear(); + feed.onAction({ type: allowedActions[0] }); + assert.notCalled(spy); + // Then add a section and check correct behaviour + SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS); + for (const action of allowedActions.concat(disallowedActions)) { + feed.onAction({ type: action }); + } + for (const action of allowedActions) { + assert.calledWith(spy, "ACTION_DISPATCHED", action); + } + for (const action of disallowedActions) { + assert.neverCalledWith(spy, "ACTION_DISPATCHED", action); + } + }); + it("should call updateBookmarkMetadata on PLACES_BOOKMARK_ADDED", () => { + const stub = sinon.stub(SectionsManager, "updateBookmarkMetadata"); + + feed.onAction({ type: "PLACES_BOOKMARK_ADDED", data: {} }); + + assert.calledOnce(stub); + }); + it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => { + const stub = sinon.stub(SectionsManager, "updateSectionPrefs"); + + feed.onAction({ type: "UPDATE_SECTION_PREFS", data: {} }); + + assert.calledOnce(stub); + }); + it("should call SectionManager.removeSectionCard on WEBEXT_DISMISS", () => { + const stub = sinon.stub(SectionsManager, "removeSectionCard"); + + feed.onAction( + ac.WebExtEvent(at.WEBEXT_DISMISS, { source: "Foo", url: "bar.com" }) + ); + + assert.calledOnce(stub); + assert.calledWith(stub, "Foo", "bar.com"); + }); + it("should call the feed's moveSection on SECTION_MOVE", () => { + sinon.stub(feed, "moveSection"); + const id = "topsites"; + const direction = +1; + feed.onAction({ type: "SECTION_MOVE", data: { id, direction } }); + + assert.calledOnce(feed.moveSection); + assert.calledWith(feed.moveSection, id, direction); + }); + }); + describe("#moveSection", () => { + it("should Move Down correctly", () => { + feed.store.state.Sections = [ + { id: "topstories", enabled: true }, + { id: "highlights", enabled: true }, + ]; + feed.moveSection("topsites", +1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "topstories,topsites,highlights" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + feed.store.dispatch.resetHistory(); + feed.moveSection("topstories", +1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "topsites,highlights,topstories" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + }); + it("should Move Up correctly", () => { + feed.store.state.Sections = [ + { id: "topstories", enabled: true }, + { id: "highlights", enabled: true }, + ]; + feed.moveSection("topstories", -1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "topstories,topsites,highlights" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + feed.store.dispatch.resetHistory(); + feed.moveSection("highlights", -1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "topsites,highlights,topstories" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + }); + it("should skip over sections that aren't enabled", () => { + feed.store.state.Sections = [ + { id: "topstories", enabled: false }, + { id: "highlights", enabled: true }, + ]; + feed.moveSection("highlights", -1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "highlights,topsites,topstories" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + feed.store.dispatch.resetHistory(); + feed.moveSection("topsites", +1); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { name: "sectionOrder", value: "topstories,highlights,topsites" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ShortUrl.test.js b/browser/components/newtab/test/unit/lib/ShortUrl.test.js new file mode 100644 index 0000000000..e0f6688db8 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ShortUrl.test.js @@ -0,0 +1,104 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { shortURL } from "lib/ShortURL.jsm"; + +const puny = "xn--kpry57d"; +const idn = "台灣"; + +describe("shortURL", () => { + let globals; + let IDNStub; + let getPublicSuffixFromHostStub; + + beforeEach(() => { + IDNStub = sinon.stub().callsFake(host => host.replace(puny, idn)); + getPublicSuffixFromHostStub = sinon.stub().returns("com"); + + globals = new GlobalOverrider(); + globals.set("IDNService", { convertToDisplayIDN: IDNStub }); + globals.set("Services", { + eTLD: { getPublicSuffixFromHost: getPublicSuffixFromHostStub }, + }); + }); + + afterEach(() => { + globals.restore(); + }); + + it("should return a blank string if url is falsey", () => { + assert.equal(shortURL({ url: false }), ""); + assert.equal(shortURL({ url: "" }), ""); + assert.equal(shortURL({}), ""); + }); + + it("should return the 'url' if not a valid url", () => { + const checkInvalid = url => assert.equal(shortURL({ url }), url); + checkInvalid(true); + checkInvalid("something"); + checkInvalid("http:"); + checkInvalid("http::double"); + checkInvalid("http://badport:65536/"); + }); + + it("should remove the eTLD", () => { + assert.equal(shortURL({ url: "http://com.blah.com" }), "com.blah"); + }); + + it("should convert host to idn when calling shortURL", () => { + assert.equal(shortURL({ url: `http://${puny}.blah.com` }), `${idn}.blah`); + }); + + it("should get the hostname from .url", () => { + assert.equal(shortURL({ url: "http://bar.com" }), "bar"); + }); + + it("should not strip out www if not first subdomain", () => { + assert.equal(shortURL({ url: "http://foo.www.com" }), "foo.www"); + }); + + it("should convert to lowercase", () => { + assert.equal(shortURL({ url: "HTTP://FOO.COM" }), "foo"); + }); + + it("should not include the port", () => { + assert.equal(shortURL({ url: "http://foo.com:8888" }), "foo"); + }); + + it("should return hostname for localhost", () => { + getPublicSuffixFromHostStub.throws("insufficient domain levels"); + + assert.equal(shortURL({ url: "http://localhost:8000/" }), "localhost"); + }); + + it("should return hostname for ip address", () => { + getPublicSuffixFromHostStub.throws("host is ip address"); + + assert.equal(shortURL({ url: "http://127.0.0.1/foo" }), "127.0.0.1"); + }); + + it("should return etld for www.gov.uk (www-only non-etld)", () => { + getPublicSuffixFromHostStub.returns("gov.uk"); + + assert.equal( + shortURL({ url: "https://www.gov.uk/countersigning" }), + "gov.uk" + ); + }); + + it("should return idn etld for www-only non-etld", () => { + getPublicSuffixFromHostStub.returns(puny); + + assert.equal(shortURL({ url: `https://www.${puny}/foo` }), idn); + }); + + it("should return not the protocol for file:", () => { + assert.equal(shortURL({ url: "file:///foo/bar.txt" }), "/foo/bar.txt"); + }); + + it("should return not the protocol for about:", () => { + assert.equal(shortURL({ url: "about:newtab" }), "newtab"); + }); + + it("should fall back to full url as a last resort", () => { + assert.equal(shortURL({ url: "about:" }), "about:"); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/SiteClassifier.test.js b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js new file mode 100644 index 0000000000..a8b09ce1f0 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/SiteClassifier.test.js @@ -0,0 +1,252 @@ +import { classifySite } from "lib/SiteClassifier.jsm"; + +const FAKE_CLASSIFIER_DATA = [ + { + type: "hostname-and-params-match", + criteria: [ + { + hostname: "hostnameandparams.com", + params: [ + { + key: "param1", + value: "val1", + }, + ], + }, + ], + weight: 300, + }, + { + type: "url-match", + criteria: [{ url: "https://fullurl.com/must/match" }], + weight: 400, + }, + { + type: "params-match", + criteria: [ + { + params: [ + { + key: "param1", + value: "val1", + }, + { + key: "param2", + value: "val2", + }, + ], + }, + ], + weight: 200, + }, + { + type: "params-prefix-match", + criteria: [ + { + params: [ + { + key: "client", + prefix: "fir", + }, + ], + }, + ], + weight: 200, + }, + { + type: "has-params", + criteria: [ + { + params: [{ key: "has-param1" }, { key: "has-param2" }], + }, + ], + weight: 100, + }, + { + type: "search-engine", + criteria: [ + { sld: "google" }, + { hostname: "bing.com" }, + { hostname: "duckduckgo.com" }, + ], + weight: 1, + }, + { + type: "news-portal", + criteria: [ + { hostname: "yahoo.com" }, + { hostname: "aol.com" }, + { hostname: "msn.com" }, + ], + weight: 1, + }, + { + type: "social-media", + criteria: [{ hostname: "facebook.com" }, { hostname: "twitter.com" }], + weight: 1, + }, + { + type: "ecommerce", + criteria: [{ sld: "amazon" }, { hostname: "ebay.com" }], + weight: 1, + }, +]; + +describe("SiteClassifier", () => { + function RemoteSettings() { + return { + get() { + return Promise.resolve(FAKE_CLASSIFIER_DATA); + }, + }; + } + + it("should return the right category", async () => { + assert.equal( + "hostname-and-params-match", + await classifySite( + "https://hostnameandparams.com?param1=val1", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite( + "https://hostnameandparams.com?param1=val", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite( + "https://hostnameandparams.com?param=val1", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite("https://hostnameandparams.com", RemoteSettings) + ); + assert.equal( + "other", + await classifySite("https://params.com?param1=val1", RemoteSettings) + ); + + assert.equal( + "url-match", + await classifySite("https://fullurl.com/must/match", RemoteSettings) + ); + assert.equal( + "other", + await classifySite("http://fullurl.com/must/match", RemoteSettings) + ); + + assert.equal( + "params-match", + await classifySite( + "https://example.com?param1=val1¶m2=val2", + RemoteSettings + ) + ); + assert.equal( + "params-match", + await classifySite( + "https://example.com?param1=val1¶m2=val2&other=other", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite( + "https://example.com?param1=val2¶m2=val1", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite("https://example.com?param1¶m2", RemoteSettings) + ); + + assert.equal( + "params-prefix-match", + await classifySite("https://search.com?client=firefox", RemoteSettings) + ); + assert.equal( + "params-prefix-match", + await classifySite("https://search.com?client=fir", RemoteSettings) + ); + assert.equal( + "other", + await classifySite( + "https://search.com?client=mozillafirefox", + RemoteSettings + ) + ); + + assert.equal( + "has-params", + await classifySite( + "https://example.com?has-param1=val1&has-param2=val2", + RemoteSettings + ) + ); + assert.equal( + "has-params", + await classifySite( + "https://example.com?has-param1&has-param2", + RemoteSettings + ) + ); + assert.equal( + "has-params", + await classifySite( + "https://example.com?has-param1&has-param2&other=other", + RemoteSettings + ) + ); + assert.equal( + "other", + await classifySite("https://example.com?has-param1", RemoteSettings) + ); + assert.equal( + "other", + await classifySite("https://example.com?has-param2", RemoteSettings) + ); + + assert.equal( + "search-engine", + await classifySite("https://google.com", RemoteSettings) + ); + assert.equal( + "search-engine", + await classifySite("https://google.de", RemoteSettings) + ); + assert.equal( + "search-engine", + await classifySite("http://bing.com/?q=firefox", RemoteSettings) + ); + + assert.equal( + "news-portal", + await classifySite("https://yahoo.com", RemoteSettings) + ); + + assert.equal( + "social-media", + await classifySite("http://twitter.com/firefox", RemoteSettings) + ); + + assert.equal( + "ecommerce", + await classifySite("https://amazon.com", RemoteSettings) + ); + assert.equal( + "ecommerce", + await classifySite("https://amazon.ca", RemoteSettings) + ); + assert.equal( + "ecommerce", + await classifySite("https://ebay.com", RemoteSettings) + ); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/Store.test.js b/browser/components/newtab/test/unit/lib/Store.test.js new file mode 100644 index 0000000000..eeeef3bf51 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/Store.test.js @@ -0,0 +1,305 @@ +import { addNumberReducer, FakePrefs } from "test/unit/utils"; +import { createStore } from "redux"; +import injector from "inject!lib/Store.jsm"; + +describe("Store", () => { + let Store; + let sandbox; + let store; + let dbStub; + beforeEach(() => { + sandbox = sinon.createSandbox(); + function ActivityStreamMessageChannel(options) { + this.dispatch = options.dispatch; + this.createChannel = sandbox.spy(); + this.destroyChannel = sandbox.spy(); + this.middleware = sandbox.spy(s => next => action => next(action)); + this.simulateMessagesForExistingTabs = sandbox.stub(); + } + dbStub = sandbox.stub().resolves(); + function FakeActivityStreamStorage() { + this.db = {}; + sinon.stub(this, "db").get(dbStub); + } + ({ Store } = injector({ + "lib/ActivityStreamMessageChannel.jsm": { ActivityStreamMessageChannel }, + "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, + "lib/ActivityStreamStorage.jsm": { + ActivityStreamStorage: FakeActivityStreamStorage, + }, + })); + store = new Store(); + sandbox.stub(store, "_initIndexedDB").resolves(); + }); + afterEach(() => { + sandbox.restore(); + }); + it("should have a .feeds property that is a Map", () => { + assert.instanceOf(store.feeds, Map); + assert.equal(store.feeds.size, 0, ".feeds.size"); + }); + it("should have a redux store at ._store", () => { + assert.ok(store._store); + assert.property(store, "dispatch"); + assert.property(store, "getState"); + }); + it("should create a ActivityStreamMessageChannel with the right dispatcher", () => { + assert.ok(store.getMessageChannel()); + assert.equal(store.getMessageChannel().dispatch, store.dispatch); + assert.equal(store.getMessageChannel(), store._messageChannel); + }); + it("should connect the ActivityStreamMessageChannel's middleware", () => { + store.dispatch({ type: "FOO" }); + assert.calledOnce(store._messageChannel.middleware); + }); + describe("#initFeed", () => { + it("should add an instance of the feed to .feeds", () => { + class Foo {} + store._prefs.set("foo", true); + store.init(new Map([["foo", () => new Foo()]])); + store.initFeed("foo"); + + assert.isTrue(store.feeds.has("foo"), "foo is set"); + assert.instanceOf(store.feeds.get("foo"), Foo); + }); + it("should call the feed's onAction with uninit action if it exists", () => { + let feed; + function createFeed() { + feed = { onAction: sinon.spy() }; + return feed; + } + const action = { type: "FOO" }; + store._feedFactories = new Map([["foo", createFeed]]); + + store.initFeed("foo", action); + + assert.calledOnce(feed.onAction); + assert.calledWith(feed.onAction, action); + }); + it("should add a .store property to the feed", () => { + class Foo {} + store._feedFactories = new Map([["foo", () => new Foo()]]); + store.initFeed("foo"); + + assert.propertyVal(store.feeds.get("foo"), "store", store); + }); + }); + describe("#uninitFeed", () => { + it("should not throw if no feed with that name exists", () => { + assert.doesNotThrow(() => { + store.uninitFeed("bar"); + }); + }); + it("should call the feed's onAction with uninit action if it exists", () => { + let feed; + function createFeed() { + feed = { onAction: sinon.spy() }; + return feed; + } + const action = { type: "BAR" }; + store._feedFactories = new Map([["foo", createFeed]]); + store.initFeed("foo"); + + store.uninitFeed("foo", action); + + assert.calledOnce(feed.onAction); + assert.calledWith(feed.onAction, action); + }); + it("should remove the feed from .feeds", () => { + class Foo {} + store._feedFactories = new Map([["foo", () => new Foo()]]); + + store.initFeed("foo"); + store.uninitFeed("foo"); + + assert.isFalse(store.feeds.has("foo"), "foo is not in .feeds"); + }); + }); + describe("onPrefChanged", () => { + beforeEach(() => { + sinon.stub(store, "initFeed"); + sinon.stub(store, "uninitFeed"); + store._prefs.set("foo", false); + store.init(new Map([["foo", () => ({})]])); + }); + it("should initialize the feed if called with true", () => { + store.onPrefChanged("foo", true); + + assert.calledWith(store.initFeed, "foo"); + assert.notCalled(store.uninitFeed); + }); + it("should uninitialize the feed if called with false", () => { + store.onPrefChanged("foo", false); + + assert.calledWith(store.uninitFeed, "foo"); + assert.notCalled(store.initFeed); + }); + it("should do nothing if not an expected feed", () => { + store.onPrefChanged("bar", false); + + assert.notCalled(store.initFeed); + assert.notCalled(store.uninitFeed); + }); + }); + describe("#init", () => { + it("should call .initFeed with each key", async () => { + sinon.stub(store, "initFeed"); + store._prefs.set("foo", true); + store._prefs.set("bar", true); + await store.init( + new Map([ + ["foo", () => {}], + ["bar", () => {}], + ]) + ); + assert.calledWith(store.initFeed, "foo"); + assert.calledWith(store.initFeed, "bar"); + }); + it("should call _initIndexedDB", async () => { + await store.init(new Map()); + + assert.calledOnce(store._initIndexedDB); + assert.calledWithExactly(store._initIndexedDB, "feeds.telemetry"); + }); + it("should access the db property of indexedDB", async () => { + store._initIndexedDB.restore(); + await store.init(new Map()); + + assert.calledOnce(dbStub); + }); + it("should reset ActivityStreamStorage telemetry if opening the db fails", async () => { + store._initIndexedDB.restore(); + // Force an IndexedDB error + dbStub.rejects(); + + await store.init(new Map()); + + assert.calledOnce(dbStub); + assert.isNull(store.dbStorage.telemetry); + }); + it("should not initialize the feed if the Pref is set to false", async () => { + sinon.stub(store, "initFeed"); + store._prefs.set("foo", false); + await store.init(new Map([["foo", () => {}]])); + assert.notCalled(store.initFeed); + }); + it("should observe the pref branch", async () => { + sinon.stub(store._prefs, "observeBranch"); + await store.init(new Map()); + assert.calledOnce(store._prefs.observeBranch); + assert.calledWith(store._prefs.observeBranch, store); + }); + it("should initialize the ActivityStreamMessageChannel channel", async () => { + await store.init(new Map()); + }); + it("should emit an initial event if provided", async () => { + sinon.stub(store, "dispatch"); + const action = { type: "FOO" }; + + await store.init(new Map(), action); + + assert.calledOnce(store.dispatch); + assert.calledWith(store.dispatch, action); + }); + it("should initialize the telemtry feed first", () => { + store._prefs.set("feeds.foo", true); + store._prefs.set("feeds.telemetry", true); + const telemetrySpy = sandbox.stub().returns({}); + const fooSpy = sandbox.stub().returns({}); + // Intentionally put the telemetry feed as the second item. + const feedFactories = new Map([ + ["feeds.foo", fooSpy], + ["feeds.telemetry", telemetrySpy], + ]); + store.init(feedFactories); + assert.ok(telemetrySpy.calledBefore(fooSpy)); + }); + it("should dispatch init/load events", async () => { + await store.init(new Map(), { type: "FOO" }); + + assert.calledOnce( + store.getMessageChannel().simulateMessagesForExistingTabs + ); + }); + it("should dispatch INIT before LOAD", async () => { + const init = { type: "INIT" }; + const load = { type: "TAB_LOAD" }; + sandbox.stub(store, "dispatch"); + store + .getMessageChannel() + .simulateMessagesForExistingTabs.callsFake(() => store.dispatch(load)); + await store.init(new Map(), init); + + assert.calledTwice(store.dispatch); + assert.equal(store.dispatch.firstCall.args[0], init); + assert.equal(store.dispatch.secondCall.args[0], load); + }); + }); + describe("#uninit", () => { + it("should emit an uninit event if provided on init", () => { + sinon.stub(store, "dispatch"); + const action = { type: "BAR" }; + store.init(new Map(), null, action); + + store.uninit(); + + assert.calledOnce(store.dispatch); + assert.calledWith(store.dispatch, action); + }); + it("should clear .feeds and ._feedFactories", () => { + store._prefs.set("a", true); + store.init( + new Map([ + ["a", () => ({})], + ["b", () => ({})], + ["c", () => ({})], + ]) + ); + + store.uninit(); + + assert.equal(store.feeds.size, 0); + assert.isNull(store._feedFactories); + }); + }); + describe("#getState", () => { + it("should return the redux state", () => { + store._store = createStore((prevState = 123) => prevState); + const { getState } = store; + assert.equal(getState(), 123); + }); + }); + describe("#dispatch", () => { + it("should call .onAction of each feed", async () => { + const { dispatch } = store; + const sub = { onAction: sinon.spy() }; + const action = { type: "FOO" }; + + store._prefs.set("sub", true); + await store.init(new Map([["sub", () => sub]])); + + dispatch(action); + + assert.calledWith(sub.onAction, action); + }); + it("should call the reducers", () => { + const { dispatch } = store; + store._store = createStore(addNumberReducer); + + dispatch({ type: "ADD", data: 14 }); + + assert.equal(store.getState(), 14); + }); + }); + describe("#subscribe", () => { + it("should subscribe to changes to the store", () => { + const sub = sinon.spy(); + const action = { type: "FOO" }; + + store.subscribe(sub); + store.dispatch(action); + + assert.calledOnce(sub); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js new file mode 100644 index 0000000000..4dd5febdb2 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/SystemTickFeed.test.js @@ -0,0 +1,76 @@ +import { SYSTEM_TICK_INTERVAL, SystemTickFeed } from "lib/SystemTickFeed.jsm"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { GlobalOverrider } from "test/unit/utils"; + +describe("System Tick Feed", () => { + let globals; + let instance; + let clock; + + beforeEach(() => { + globals = new GlobalOverrider(); + clock = sinon.useFakeTimers(); + + instance = new SystemTickFeed(); + instance.store = { + getState() { + return {}; + }, + dispatch() {}, + }; + }); + afterEach(() => { + globals.restore(); + clock.restore(); + }); + it("should create a SystemTickFeed", () => { + assert.instanceOf(instance, SystemTickFeed); + }); + it("should fire SYSTEM_TICK events at configured interval", () => { + globals.set("ChromeUtils", { + idleDispatch: f => f(), + }); + let expectation = sinon + .mock(instance.store) + .expects("dispatch") + .twice() + .withExactArgs({ type: at.SYSTEM_TICK }); + + instance.onAction({ type: at.INIT }); + clock.tick(SYSTEM_TICK_INTERVAL * 2); + expectation.verify(); + }); + it("should not fire SYSTEM_TICK events after UNINIT", () => { + let expectation = sinon.mock(instance.store).expects("dispatch").never(); + + instance.onAction({ type: at.UNINIT }); + clock.tick(SYSTEM_TICK_INTERVAL * 2); + expectation.verify(); + }); + it("should not fire SYSTEM_TICK events while the user is away", () => { + let expectation = sinon.mock(instance.store).expects("dispatch").never(); + + instance.onAction({ type: at.INIT }); + instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 }; + clock.tick(SYSTEM_TICK_INTERVAL * 3); + expectation.verify(); + instance.onAction({ type: at.UNINIT }); + }); + it("should fire SYSTEM_TICK immediately when the user is active again", () => { + globals.set("ChromeUtils", { + idleDispatch: f => f(), + }); + let expectation = sinon + .mock(instance.store) + .expects("dispatch") + .once() + .withExactArgs({ type: at.SYSTEM_TICK }); + + instance.onAction({ type: at.INIT }); + instance._idleService = { idleTime: SYSTEM_TICK_INTERVAL + 1 }; + clock.tick(SYSTEM_TICK_INTERVAL * 3); + instance.observe(); + expectation.verify(); + instance.onAction({ type: at.UNINIT }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js new file mode 100644 index 0000000000..1606f98e94 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/TelemetryFeed.test.js @@ -0,0 +1,2606 @@ +/* global Services */ +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { + ASRouterEventPing, + BasePing, + ImpressionStatsPing, + SessionPing, + UserEventPing, +} from "test/schemas/pings"; +import { FAKE_GLOBAL_PREFS, GlobalOverrider } from "test/unit/utils"; +import { ASRouterPreferences } from "lib/ASRouterPreferences.jsm"; +import injector from "inject!lib/TelemetryFeed.jsm"; +import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.sys.mjs"; + +const FAKE_UUID = "{foo-123-foo}"; +const FAKE_ROUTER_MESSAGE_PROVIDER = [{ id: "cfr", enabled: true }]; +const FAKE_TELEMETRY_ID = "foo123"; + +// eslint-disable-next-line max-statements +describe("TelemetryFeed", () => { + let globals; + let sandbox; + let expectedUserPrefs; + let browser = { + getAttribute() { + return "true"; + }, + }; + let instance; + let clock; + let fakeHomePageUrl; + let fakeHomePage; + let fakeExtensionSettingsStore; + let ExperimentAPI = { getExperimentMetaData: () => {} }; + class PingCentre { + sendPing() {} + uninit() {} + sendStructuredIngestionPing() {} + } + class UTEventReporting { + sendUserEvent() {} + sendSessionEndEvent() {} + uninit() {} + } + + // Reset the global prefs before importing the `TelemetryFeed` module, to + // avoid a coverage miss caused by preference pollution when this test and + // `ActivityStream.test.js` are run together. + // + // The `TelemetryFeed` module defines a lazy `contextId` getter, which the + // `XPCOMUtils.defineLazyGetter` mock (defined in `unit-entry.js`) executes + // immediately, as soon as the module is imported. + // + // If this test runs first, there's no coverage miss: this test will load + // the `TelemetryFeed` module and run the lazy `contextId` getter, which will + // generate a fake context ID and store it in `FAKE_GLOBAL_PREFS`, covering + // all branches in the module. When `ActivityStream.test.js` runs, it'll load + // `TelemetryFeed` and run the lazy getter a second time, which will use the + // existing fake context ID from `FAKE_GLOBAL_PREFS` instead of generating a + // new one. + // + // But, if `ActivityStream.test.js` runs first, then loading `TelemetryFeed` a + // second time as part of this test will use the existing fake context ID from + // `FAKE_GLOBAL_PREFS`, missing coverage for the branch to generate a new + // context ID. + FAKE_GLOBAL_PREFS.clear(); + + const { + TelemetryFeed, + USER_PREFS_ENCODING, + PREF_IMPRESSION_ID, + TELEMETRY_PREF, + EVENTS_TELEMETRY_PREF, + STRUCTURED_INGESTION_ENDPOINT_PREF, + } = injector({ + "lib/UTEventReporting.sys.mjs": { UTEventReporting }, + }); + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + clock = sinon.useFakeTimers(); + fakeHomePageUrl = "about:home"; + fakeHomePage = { + get() { + return fakeHomePageUrl; + }, + }; + fakeExtensionSettingsStore = { + initialize() { + return Promise.resolve(); + }, + getSetting() {}, + }; + sandbox.spy(global.console, "error"); + globals.set("AboutNewTab", { + newTabURLOverridden: false, + newTabURL: "", + }); + globals.set("pktApi", { + isUserLoggedIn: () => true, + }); + globals.set("HomePage", fakeHomePage); + globals.set("ExtensionSettingsStore", fakeExtensionSettingsStore); + globals.set("PingCentre", PingCentre); + globals.set("UTEventReporting", UTEventReporting); + globals.set("ClientID", { + getClientID: sandbox.spy(async () => FAKE_TELEMETRY_ID), + }); + globals.set("ExperimentAPI", ExperimentAPI); + + sandbox + .stub(ASRouterPreferences, "providers") + .get(() => FAKE_ROUTER_MESSAGE_PROVIDER); + instance = new TelemetryFeed(); + }); + afterEach(() => { + clock.restore(); + globals.restore(); + FAKE_GLOBAL_PREFS.clear(); + ASRouterPreferences.uninit(); + }); + describe("#init", () => { + it("should create an instance", () => { + const testInstance = new TelemetryFeed(); + assert.isDefined(testInstance); + }); + it("should add .pingCentre, a PingCentre instance", () => { + assert.instanceOf(instance.pingCentre, PingCentre); + }); + it("should add .utEvents, a UTEventReporting instance", () => { + assert.instanceOf(instance.utEvents, UTEventReporting); + }); + it("should make this.browserOpenNewtabStart() observe browser-open-newtab-start", () => { + sandbox.spy(Services.obs, "addObserver"); + + instance.init(); + + assert.calledTwice(Services.obs.addObserver); + assert.calledWithExactly( + Services.obs.addObserver, + instance.browserOpenNewtabStart, + "browser-open-newtab-start" + ); + }); + it("should add window open listener", () => { + sandbox.spy(Services.obs, "addObserver"); + + instance.init(); + + assert.calledTwice(Services.obs.addObserver); + assert.calledWithExactly( + Services.obs.addObserver, + instance._addWindowListeners, + "domwindowopened" + ); + }); + it("should add TabPinned event listener on new windows", () => { + const stub = { addEventListener: sandbox.stub() }; + sandbox.spy(Services.obs, "addObserver"); + + instance.init(); + + assert.calledTwice(Services.obs.addObserver); + const [cb] = Services.obs.addObserver.secondCall.args; + cb(stub); + assert.calledTwice(stub.addEventListener); + assert.calledWithExactly( + stub.addEventListener, + "unload", + instance.handleEvent + ); + assert.calledWithExactly( + stub.addEventListener, + "TabPinned", + instance.handleEvent + ); + }); + it("should create impression id if none exists", () => { + assert.equal(instance._impressionId, FAKE_UUID); + }); + it("should set impression id if it exists", () => { + FAKE_GLOBAL_PREFS.set(PREF_IMPRESSION_ID, "fakeImpressionId"); + assert.equal(new TelemetryFeed()._impressionId, "fakeImpressionId"); + }); + it("should register listeners on existing windows", () => { + const stub = sandbox.stub(); + globals.set({ + Services: { + ...Services, + wm: { getEnumerator: () => [{ addEventListener: stub }] }, + }, + }); + + instance.init(); + + assert.calledTwice(stub); + assert.calledWithExactly(stub, "unload", instance.handleEvent); + assert.calledWithExactly(stub, "TabPinned", instance.handleEvent); + }); + describe("telemetry pref changes from false to true", () => { + beforeEach(() => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, false); + instance = new TelemetryFeed(); + + assert.propertyVal(instance, "telemetryEnabled", false); + }); + + it("should set the enabled property to true", () => { + instance._prefs.set(TELEMETRY_PREF, true); + + assert.propertyVal(instance, "telemetryEnabled", true); + }); + }); + describe("events telemetry pref changes from false to true", () => { + beforeEach(() => { + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, false); + instance = new TelemetryFeed(); + + assert.propertyVal(instance, "eventTelemetryEnabled", false); + }); + + it("should set the enabled property to true", () => { + instance._prefs.set(EVENTS_TELEMETRY_PREF, true); + + assert.propertyVal(instance, "eventTelemetryEnabled", true); + }); + }); + it("should set two scalars for deletion-request", () => { + sandbox.spy(Services.telemetry, "scalarSet"); + + instance.init(); + + assert.calledTwice(Services.telemetry.scalarSet); + + // impression_id + let [type, value] = Services.telemetry.scalarSet.firstCall.args; + assert.equal(type, "deletion.request.impression_id"); + assert.equal(value, instance._impressionId); + + // context_id + [type, value] = Services.telemetry.scalarSet.secondCall.args; + assert.equal(type, "deletion.request.context_id"); + assert.equal(value, FAKE_UUID); + }); + describe("#_beginObservingNewtabPingPrefs", () => { + it("should record initial metrics from newtab prefs", () => { + FAKE_GLOBAL_PREFS.set( + "browser.newtabpage.activity-stream.feeds.topsites", + true + ); + FAKE_GLOBAL_PREFS.set( + "browser.newtabpage.activity-stream.topSitesRows", + 3 + ); + FAKE_GLOBAL_PREFS.set( + "browser.topsites.blockedSponsors", + '["mozilla"]' + ); + + sandbox.spy(Glean.topsites.enabled, "set"); + sandbox.spy(Glean.topsites.rows, "set"); + sandbox.spy(Glean.newtab.blockedSponsors, "set"); + + instance = new TelemetryFeed(); + instance.init(); + + assert.calledOnce(Glean.topsites.enabled.set); + assert.calledWith(Glean.topsites.enabled.set, true); + assert.calledOnce(Glean.topsites.rows.set); + assert.calledWith(Glean.topsites.rows.set, 3); + assert.calledOnce(Glean.newtab.blockedSponsors.set); + assert.calledWith(Glean.newtab.blockedSponsors.set, ["mozilla"]); + }); + + it("should not record blocked sponsor metrics when bad json string is passed", () => { + FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "BAD[JSON]"); + + sandbox.spy(Glean.newtab.blockedSponsors, "set"); + + instance = new TelemetryFeed(); + instance.init(); + + assert.notCalled(Glean.newtab.blockedSponsors.set); + }); + + it("should record new metrics for newtab pref changes", () => { + FAKE_GLOBAL_PREFS.set( + "browser.newtabpage.activity-stream.topSitesRows", + 3 + ); + FAKE_GLOBAL_PREFS.set("browser.topsites.blockedSponsors", "[]"); + sandbox.spy(Glean.topsites.rows, "set"); + sandbox.spy(Glean.newtab.blockedSponsors, "set"); + + instance = new TelemetryFeed(); + instance.init(); + + Services.prefs.setIntPref( + "browser.newtabpage.activity-stream.topSitesRows", + 2 + ); + + Services.prefs.setStringPref( + "browser.topsites.blockedSponsors", + '["mozilla"]' + ); + + assert.calledTwice(Glean.topsites.rows.set); + assert.calledWith(Glean.topsites.rows.set.firstCall, 3); + assert.calledWith(Glean.topsites.rows.set.secondCall, 2); + assert.calledWith(Glean.newtab.blockedSponsors.set.firstCall, []); + assert.calledWith(Glean.newtab.blockedSponsors.set.secondCall, [ + "mozilla", + ]); + }); + it("should ignore changes to other prefs", () => { + FAKE_GLOBAL_PREFS.set("some.other.pref", 123); + FAKE_GLOBAL_PREFS.set( + "browser.newtabpage.activity-stream.impressionId", + "{foo-123-foo}" + ); + + instance = new TelemetryFeed(); + instance.init(); + + Services.prefs.setIntPref("some.other.pref", 456); + Services.prefs.setCharPref( + "browser.newtabpage.activity-stream.impressionId", + "{foo-456-foo}" + ); + }); + }); + }); + describe("#handleEvent", () => { + it("should dispatch a TAB_PINNED_EVENT", () => { + sandbox.stub(instance, "sendEvent"); + globals.set({ + Services: { + ...Services, + wm: { + getEnumerator: () => [{ gBrowser: { tabs: [{ pinned: true }] } }], + }, + }, + }); + + instance.handleEvent({ type: "TabPinned", target: {} }); + + assert.calledOnce(instance.sendEvent); + const [ping] = instance.sendEvent.firstCall.args; + assert.propertyVal(ping, "event", "TABPINNED"); + assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU"); + assert.propertyVal(ping, "session_id", "n/a"); + assert.propertyVal(ping.value, "total_pinned_tabs", 1); + }); + it("should skip private windows", () => { + sandbox.stub(instance, "sendEvent"); + globals.set({ PrivateBrowsingUtils: { isWindowPrivate: () => true } }); + + instance.handleEvent({ type: "TabPinned", target: {} }); + + assert.notCalled(instance.sendEvent); + }); + it("should return the correct value for total_pinned_tabs", () => { + sandbox.stub(instance, "sendEvent"); + globals.set({ + Services: { + ...Services, + wm: { + getEnumerator: () => [ + { + gBrowser: { tabs: [{ pinned: true }, { pinned: false }] }, + }, + ], + }, + }, + }); + + instance.handleEvent({ type: "TabPinned", target: {} }); + + assert.calledOnce(instance.sendEvent); + const [ping] = instance.sendEvent.firstCall.args; + assert.propertyVal(ping, "event", "TABPINNED"); + assert.propertyVal(ping, "source", "TAB_CONTEXT_MENU"); + assert.propertyVal(ping, "session_id", "n/a"); + assert.propertyVal(ping.value, "total_pinned_tabs", 1); + }); + it("should return the correct value for total_pinned_tabs (when private windows are open)", () => { + sandbox.stub(instance, "sendEvent"); + const privateWinStub = sandbox + .stub() + .onCall(0) + .returns(false) + .onCall(1) + .returns(true); + globals.set({ + PrivateBrowsingUtils: { isWindowPrivate: privateWinStub }, + }); + globals.set({ + Services: { + ...Services, + wm: { + getEnumerator: () => [ + { + gBrowser: { tabs: [{ pinned: true }, { pinned: true }] }, + }, + ], + }, + }, + }); + + instance.handleEvent({ type: "TabPinned", target: {} }); + + assert.calledOnce(instance.sendEvent); + const [ping] = instance.sendEvent.firstCall.args; + assert.propertyVal(ping.value, "total_pinned_tabs", 0); + }); + it("should unregister the event listeners", () => { + const stub = { removeEventListener: sandbox.stub() }; + + instance.handleEvent({ type: "unload", target: stub }); + + assert.calledTwice(stub.removeEventListener); + assert.calledWithExactly( + stub.removeEventListener, + "unload", + instance.handleEvent + ); + assert.calledWithExactly( + stub.removeEventListener, + "TabPinned", + instance.handleEvent + ); + }); + }); + describe("#addSession", () => { + it("should add a session and return it", () => { + const session = instance.addSession("foo"); + + assert.equal(instance.sessions.get("foo"), session); + }); + it("should set the session_id", () => { + sandbox.spy(Services.uuid, "generateUUID"); + + const session = instance.addSession("foo"); + + assert.calledOnce(Services.uuid.generateUUID); + assert.equal( + session.session_id, + Services.uuid.generateUUID.firstCall.returnValue + ); + }); + it("should set the page if a url parameter is given", () => { + const session = instance.addSession("foo", "about:monkeys"); + + assert.propertyVal(session, "page", "about:monkeys"); + }); + it("should set the page prop to 'unknown' if no URL parameter given", () => { + const session = instance.addSession("foo"); + + assert.propertyVal(session, "page", "unknown"); + }); + it("should set the perf type when lacking timestamp", () => { + const session = instance.addSession("foo"); + + assert.propertyVal(session.perf, "load_trigger_type", "unexpected"); + }); + it("should set load_trigger_type to first_window_opened on the first about:home seen", () => { + const session = instance.addSession("foo", "about:home"); + + assert.propertyVal( + session.perf, + "load_trigger_type", + "first_window_opened" + ); + }); + it("should not set load_trigger_type to first_window_opened on the second about:home seen", () => { + instance.addSession("foo", "about:home"); + + const session2 = instance.addSession("foo", "about:home"); + + assert.notPropertyVal( + session2.perf, + "load_trigger_type", + "first_window_opened" + ); + }); + it("should set load_trigger_ts to the value of the process start timestamp", () => { + const session = instance.addSession("foo", "about:home"); + + assert.propertyVal(session.perf, "load_trigger_ts", 1588010448000); + }); + it("should create a valid session ping on the first about:home seen", () => { + // Add a session + const portID = "foo"; + const session = instance.addSession(portID, "about:home"); + + // Create a ping referencing the session + const ping = instance.createSessionEndEvent(session); + assert.validate(ping, SessionPing); + }); + it("should be a valid ping with the data_late_by_ms perf", () => { + // Add a session + const portID = "foo"; + const session = instance.addSession(portID, "about:home"); + instance.saveSessionPerfData("foo", { topsites_data_late_by_ms: 10 }); + instance.saveSessionPerfData("foo", { highlights_data_late_by_ms: 20 }); + + // Create a ping referencing the session + const ping = instance.createSessionEndEvent(session); + assert.validate(ping, SessionPing); + assert.propertyVal( + instance.sessions.get("foo").perf, + "highlights_data_late_by_ms", + 20 + ); + assert.propertyVal( + instance.sessions.get("foo").perf, + "topsites_data_late_by_ms", + 10 + ); + }); + it("should be a valid ping with the topsites stats perf", () => { + // Add a session + const portID = "foo"; + const session = instance.addSession(portID, "about:home"); + instance.saveSessionPerfData("foo", { + topsites_icon_stats: { + custom_screenshot: 0, + screenshot_with_icon: 2, + screenshot: 1, + tippytop: 2, + rich_icon: 1, + no_image: 0, + }, + topsites_pinned: 3, + topsites_search_shortcuts: 2, + }); + + // Create a ping referencing the session + const ping = instance.createSessionEndEvent(session); + assert.validate(ping, SessionPing); + assert.propertyVal( + instance.sessions.get("foo").perf.topsites_icon_stats, + "screenshot_with_icon", + 2 + ); + assert.equal(instance.sessions.get("foo").perf.topsites_pinned, 3); + assert.equal( + instance.sessions.get("foo").perf.topsites_search_shortcuts, + 2 + ); + }); + }); + + describe("#browserOpenNewtabStart", () => { + it("should call ChromeUtils.addProfilerMarker with browser-open-newtab-start", () => { + globals.set("ChromeUtils", { + addProfilerMarker: sandbox.stub(), + }); + + sandbox.stub(global.Cu, "now").returns(12345); + + instance.browserOpenNewtabStart(); + + assert.calledOnce(ChromeUtils.addProfilerMarker); + assert.calledWithExactly( + ChromeUtils.addProfilerMarker, + "UserTiming", + 12345, + "browser-open-newtab-start" + ); + }); + }); + + describe("#endSession", () => { + it("should not throw if there is no session for the given port ID", () => { + assert.doesNotThrow(() => instance.endSession("doesn't exist")); + }); + it("should add a session_duration integer if there is a visibility_event_rcvd_ts", () => { + sandbox.stub(instance, "sendEvent"); + const session = instance.addSession("foo"); + session.perf.visibility_event_rcvd_ts = 444.4732; + + instance.endSession("foo"); + + assert.isNumber(session.session_duration); + assert.ok( + Number.isInteger(session.session_duration), + "session_duration should be an integer" + ); + }); + it("shouldn't send session ping if there's no visibility_event_rcvd_ts", () => { + sandbox.stub(instance, "sendEvent"); + instance.addSession("foo"); + + instance.endSession("foo"); + + assert.notCalled(instance.sendEvent); + assert.isFalse(instance.sessions.has("foo")); + }); + it("should remove the session from .sessions", () => { + sandbox.stub(instance, "sendEvent"); + instance.addSession("foo"); + + instance.endSession("foo"); + + assert.isFalse(instance.sessions.has("foo")); + }); + it("should call createSessionSendEvent and sendEvent with the sesssion", () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true); + instance = new TelemetryFeed(); + + sandbox.stub(instance, "sendEvent"); + sandbox.stub(instance, "createSessionEndEvent"); + sandbox.stub(instance.utEvents, "sendSessionEndEvent"); + const session = instance.addSession("foo"); + session.perf.visibility_event_rcvd_ts = 444.4732; + + instance.endSession("foo"); + + // Did we call sendEvent with the result of createSessionEndEvent? + assert.calledWith(instance.createSessionEndEvent, session); + + let sessionEndEvent = + instance.createSessionEndEvent.firstCall.returnValue; + assert.calledWith(instance.sendEvent, sessionEndEvent); + assert.calledWith(instance.utEvents.sendSessionEndEvent, sessionEndEvent); + }); + }); + describe("ping creators", () => { + beforeEach(() => { + for (const pref of Object.keys(USER_PREFS_ENCODING)) { + FAKE_GLOBAL_PREFS.set(pref, true); + expectedUserPrefs |= USER_PREFS_ENCODING[pref]; + } + instance.init(); + }); + describe("#createPing", () => { + it("should create a valid base ping without a session if no portID is supplied", async () => { + const ping = await instance.createPing(); + assert.validate(ping, BasePing); + assert.notProperty(ping, "session_id"); + assert.notProperty(ping, "page"); + }); + it("should create a valid base ping with session info if a portID is supplied", async () => { + // Add a session + const portID = "foo"; + instance.addSession(portID, "about:home"); + const sessionID = instance.sessions.get(portID).session_id; + + // Create a ping referencing the session + const ping = await instance.createPing(portID); + assert.validate(ping, BasePing); + + // Make sure we added the right session-related stuff to the ping + assert.propertyVal(ping, "session_id", sessionID); + assert.propertyVal(ping, "page", "about:home"); + }); + it("should create an unexpected base ping if no session yet portID is supplied", async () => { + const ping = await instance.createPing("foo"); + + assert.validate(ping, BasePing); + assert.propertyVal(ping, "page", "unknown"); + assert.propertyVal( + instance.sessions.get("foo").perf, + "load_trigger_type", + "unexpected" + ); + }); + it("should create a base ping with user_prefs", async () => { + const ping = await instance.createPing("foo"); + + assert.validate(ping, BasePing); + assert.propertyVal(ping, "user_prefs", expectedUserPrefs); + }); + }); + describe("#createUserEvent", () => { + it("should create a valid event", async () => { + const portID = "foo"; + const data = { source: "TOP_SITES", event: "CLICK" }; + const action = ac.AlsoToMain(ac.UserEvent(data), portID); + const session = instance.addSession(portID); + + const ping = await instance.createUserEvent(action); + + // Is it valid? + assert.validate(ping, UserEventPing); + // Does it have the right session_id? + assert.propertyVal(ping, "session_id", session.session_id); + }); + }); + describe("#createSessionEndEvent", () => { + it("should create a valid event", async () => { + const ping = await instance.createSessionEndEvent({ + session_id: FAKE_UUID, + page: "about:newtab", + session_duration: 12345, + perf: { + load_trigger_ts: 10, + load_trigger_type: "menu_plus_or_keyboard", + visibility_event_rcvd_ts: 20, + is_preloaded: true, + }, + }); + + // Is it valid? + assert.validate(ping, SessionPing); + assert.propertyVal(ping, "session_id", FAKE_UUID); + assert.propertyVal(ping, "page", "about:newtab"); + assert.propertyVal(ping, "session_duration", 12345); + }); + it("should create a valid unexpected session event", async () => { + const ping = await instance.createSessionEndEvent({ + session_id: FAKE_UUID, + page: "about:newtab", + session_duration: 12345, + perf: { + load_trigger_type: "unexpected", + is_preloaded: true, + }, + }); + + // Is it valid? + assert.validate(ping, SessionPing); + assert.propertyVal(ping, "session_id", FAKE_UUID); + assert.propertyVal(ping, "page", "about:newtab"); + assert.propertyVal(ping, "session_duration", 12345); + assert.propertyVal(ping.perf, "load_trigger_type", "unexpected"); + }); + }); + }); + describe("#createImpressionStats", () => { + it("should create a valid impression stats ping", async () => { + const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.validate(ping, ImpressionStatsPing); + assert.propertyVal(ping, "source", "POCKET"); + assert.propertyVal(ping, "tiles", tiles); + }); + it("should create a valid click ping", async () => { + const tiles = [{ id: 10001, pos: 2 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles, click: 0 }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.validate(ping, ImpressionStatsPing); + assert.propertyVal(ping, "click", 0); + assert.propertyVal(ping, "tiles", tiles); + }); + it("should create a valid block ping", async () => { + const tiles = [{ id: 10001, pos: 2 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles, block: 0 }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.validate(ping, ImpressionStatsPing); + assert.propertyVal(ping, "block", 0); + assert.propertyVal(ping, "tiles", tiles); + }); + it("should create a valid pocket ping", async () => { + const tiles = [{ id: 10001, pos: 2 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles, pocket: 0 }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.validate(ping, ImpressionStatsPing); + assert.propertyVal(ping, "pocket", 0); + assert.propertyVal(ping, "tiles", tiles); + }); + it("should pass shim if it is available to impression ping", async () => { + const tiles = [{ id: 10001, pos: 2, shim: 1234 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.propertyVal(ping, "tiles", tiles); + assert.propertyVal(ping.tiles[0], "shim", tiles[0].shim); + }); + it("should not include client_id and session_id", async () => { + const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles }); + const ping = await instance.createImpressionStats( + au.getPortIdOfSender(action), + action.data + ); + + assert.validate(ping, ImpressionStatsPing); + assert.notProperty(ping, "client_id"); + assert.notProperty(ping, "session_id"); + }); + }); + describe("#applyCFRPolicy", () => { + it("should use client_id and message_id in prerelease", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "nightly"; + }, + }); + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + const { ping, pingType } = await instance.applyCFRPolicy(data); + + assert.equal(pingType, "cfr"); + assert.isUndefined(ping.impression_id); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "bucket_id", "cfr_bucket_01"); + assert.propertyVal(ping, "message_id", "cfr_message_01"); + }); + it("should use impression_id and bucket_id in release", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + const { ping, pingType } = await instance.applyCFRPolicy(data); + + assert.equal(pingType, "cfr"); + assert.isUndefined(ping.client_id); + assert.propertyVal(ping, "impression_id", FAKE_UUID); + assert.propertyVal(ping, "message_id", "n/a"); + assert.propertyVal(ping, "bucket_id", "cfr_bucket_01"); + }); + it("should use client_id and message_id in the experiment cohort in release", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + const { ping, pingType } = await instance.applyCFRPolicy(data); + + assert.equal(pingType, "cfr"); + assert.isUndefined(ping.impression_id); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "bucket_id", "cfr_bucket_01"); + assert.propertyVal(ping, "message_id", "cfr_message_01"); + }); + it("should use impression_id and bucket_id in Private Browsing", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + is_private: true, + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + const { ping, pingType } = await instance.applyCFRPolicy(data); + + assert.equal(pingType, "cfr"); + assert.isUndefined(ping.client_id); + assert.propertyVal(ping, "impression_id", FAKE_UUID); + assert.propertyVal(ping, "message_id", "n/a"); + assert.propertyVal(ping, "bucket_id", "cfr_bucket_01"); + }); + it("should use client_id and message_id in the experiment cohort in Private Browsing", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + is_private: true, + message_id: "cfr_message_01", + bucket_id: "cfr_bucket_01", + }; + const { ping, pingType } = await instance.applyCFRPolicy(data); + + assert.equal(pingType, "cfr"); + assert.isUndefined(ping.impression_id); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "bucket_id", "cfr_bucket_01"); + assert.propertyVal(ping, "message_id", "cfr_message_01"); + }); + }); + describe("#applyWhatsNewPolicy", () => { + it("should set client_id and set pingType", async () => { + const { ping, pingType } = await instance.applyWhatsNewPolicy({}); + + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.equal(pingType, "whats-new-panel"); + }); + }); + describe("#applyInfoBarPolicy", () => { + it("should set client_id and set pingType", async () => { + const { ping, pingType } = await instance.applyInfoBarPolicy({}); + + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.equal(pingType, "infobar"); + }); + }); + describe("#applyToastNotificationPolicy", () => { + it("should set client_id and set pingType", async () => { + const { ping, pingType } = await instance.applyToastNotificationPolicy( + {} + ); + + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.equal(pingType, "toast_notification"); + }); + }); + describe("#applySpotlightPolicy", () => { + it("should set client_id and set pingType", async () => { + let pingData = { action: "foo" }; + const { ping, pingType } = await instance.applySpotlightPolicy(pingData); + + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.equal(pingType, "spotlight"); + assert.notProperty(ping, "action"); + }); + }); + describe("#applyMomentsPolicy", () => { + it("should use client_id and message_id in prerelease", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "nightly"; + }, + }); + const data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + const { ping, pingType } = await instance.applyMomentsPolicy(data); + + assert.equal(pingType, "moments"); + assert.isUndefined(ping.impression_id); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "bucket_id", "moments_bucket_01"); + assert.propertyVal(ping, "message_id", "moments_message_01"); + }); + it("should use impression_id and bucket_id in release", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + const data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + const { ping, pingType } = await instance.applyMomentsPolicy(data); + + assert.equal(pingType, "moments"); + assert.isUndefined(ping.client_id); + assert.propertyVal(ping, "impression_id", FAKE_UUID); + assert.propertyVal(ping, "message_id", "n/a"); + assert.propertyVal(ping, "bucket_id", "moments_bucket_01"); + }); + it("should use client_id and message_id in the experiment cohort in release", async () => { + globals.set("UpdateUtils", { + getUpdateChannel() { + return "release"; + }, + }); + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + const data = { + action: "moments_user_event", + event: "IMPRESSION", + message_id: "moments_message_01", + bucket_id: "moments_bucket_01", + }; + const { ping, pingType } = await instance.applyMomentsPolicy(data); + + assert.equal(pingType, "moments"); + assert.isUndefined(ping.impression_id); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "bucket_id", "moments_bucket_01"); + assert.propertyVal(ping, "message_id", "moments_message_01"); + }); + }); + describe("#applySnippetsPolicy", () => { + it("should include client_id", async () => { + const data = { + action: "snippets_user_event", + event: "IMPRESSION", + message_id: "snippets_message_01", + }; + const { ping, pingType } = await instance.applySnippetsPolicy(data); + + assert.equal(pingType, "snippets"); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "message_id", "snippets_message_01"); + }); + }); + describe("#applyOnboardingPolicy", () => { + it("should include client_id", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + const { ping, pingType } = await instance.applyOnboardingPolicy(data); + + assert.equal(pingType, "onboarding"); + assert.propertyVal(ping, "client_id", FAKE_TELEMETRY_ID); + assert.propertyVal(ping, "message_id", "onboarding_message_01"); + assert.propertyVal(ping, "browser_session_id", "fake_session_id"); + }); + it("should include page to event_context if there is a session", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + const session = { page: "about:welcome" }; + const { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + assert.equal(pingType, "onboarding"); + assert.propertyVal( + ping, + "event_context", + JSON.stringify({ page: "about:welcome" }) + ); + assert.propertyVal(ping, "message_id", "onboarding_message_01"); + }); + it("should not set page if it is not in ONBOARDING_ALLOWED_PAGE_VALUES", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + }; + const session = { page: "foo" }; + const { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + assert.calledOnce(global.console.error); + assert.equal(pingType, "onboarding"); + assert.propertyVal(ping, "event_context", JSON.stringify({})); + assert.propertyVal(ping, "message_id", "onboarding_message_01"); + }); + it("should append page to event_context if it is not empty", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + event_context: JSON.stringify({ foo: "bar" }), + }; + const session = { page: "about:welcome" }; + const { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + assert.equal(pingType, "onboarding"); + assert.propertyVal( + ping, + "event_context", + JSON.stringify({ foo: "bar", page: "about:welcome" }) + ); + assert.propertyVal(ping, "message_id", "onboarding_message_01"); + }); + it("should append page to event_context if it is not a JSON serialized string", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTION", + message_id: "onboarding_message_01", + event_context: "foo", + }; + const session = { page: "about:welcome" }; + const { ping, pingType } = await instance.applyOnboardingPolicy( + data, + session + ); + + assert.equal(pingType, "onboarding"); + assert.propertyVal( + ping, + "event_context", + JSON.stringify({ value: "foo", page: "about:welcome" }) + ); + assert.propertyVal(ping, "message_id", "onboarding_message_01"); + }); + }); + describe("#applyUndesiredEventPolicy", () => { + it("should exclude client_id and use impression_id", () => { + const data = { + action: "asrouter_undesired_event", + event: "RS_MISSING_DATA", + }; + const { ping, pingType } = instance.applyUndesiredEventPolicy(data); + + assert.equal(pingType, "undesired-events"); + assert.isUndefined(ping.client_id); + assert.propertyVal(ping, "impression_id", FAKE_UUID); + }); + }); + describe("#createASRouterEvent", () => { + it("should create a valid AS Router event", async () => { + const data = { + action: "snippets_user_event", + event: "CLICK", + message_id: "snippets_message_01", + }; + const action = ac.ASRouterUserEvent(data); + const { ping } = await instance.createASRouterEvent(action); + + assert.validate(ping, ASRouterEventPing); + assert.propertyVal(ping, "event", "CLICK"); + }); + it("should call applyCFRPolicy if action equals to cfr_user_event", async () => { + const data = { + action: "cfr_user_event", + event: "IMPRESSION", + message_id: "cfr_message_01", + }; + sandbox.stub(instance, "applyCFRPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyCFRPolicy); + }); + it("should call applySnippetsPolicy if action equals to snippets_user_event", async () => { + const data = { + action: "snippets_user_event", + event: "IMPRESSION", + message_id: "snippets_message_01", + }; + sandbox.stub(instance, "applySnippetsPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applySnippetsPolicy); + }); + it("should call applySnippetsPolicy if action equals to snippets_local_testing_user_event", async () => { + const data = { + action: "snippets_local_testing_user_event", + event: "IMPRESSION", + message_id: "snippets_message_01", + }; + sandbox.stub(instance, "applySnippetsPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applySnippetsPolicy); + }); + it("should call applyOnboardingPolicy if action equals to onboarding_user_event", async () => { + const data = { + action: "onboarding_user_event", + event: "CLICK_BUTTON", + message_id: "onboarding_message_01", + }; + sandbox.stub(instance, "applyOnboardingPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyOnboardingPolicy); + }); + it("should call applyWhatsNewPolicy if action equals to whats-new-panel_user_event", async () => { + const data = { + action: "whats-new-panel_user_event", + event: "CLICK_BUTTON", + message_id: "whats-new-panel_message_01", + }; + sandbox.stub(instance, "applyWhatsNewPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyWhatsNewPolicy); + }); + it("should call applyMomentsPolicy if action equals to moments_user_event", async () => { + const data = { + action: "moments_user_event", + event: "CLICK_BUTTON", + message_id: "moments_message_01", + }; + sandbox.stub(instance, "applyMomentsPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyMomentsPolicy); + }); + it("should call applySpotlightPolicy if action equals to spotlight_user_event", async () => { + const data = { + action: "spotlight_user_event", + event: "CLICK", + message_id: "SPOTLIGHT_MESSAGE_93", + }; + sandbox.stub(instance, "applySpotlightPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applySpotlightPolicy); + }); + it("should call applyToastNotificationPolicy if action equals to toast_notification_user_event", async () => { + const data = { + action: "toast_notification_user_event", + event: "IMPRESSION", + message_id: "TEST_TOAST_NOTIFICATION1", + }; + sandbox.stub(instance, "applyToastNotificationPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyToastNotificationPolicy); + }); + it("should call applyUndesiredEventPolicy if action equals to asrouter_undesired_event", async () => { + const data = { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + }; + sandbox.stub(instance, "applyUndesiredEventPolicy"); + const action = ac.ASRouterUserEvent(data); + await instance.createASRouterEvent(action); + + assert.calledOnce(instance.applyUndesiredEventPolicy); + }); + it("should stringify event_context if it is an Object", async () => { + const data = { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + event_context: { foo: "bar" }, + }; + const action = ac.ASRouterUserEvent(data); + const { ping } = await instance.createASRouterEvent(action); + + assert.propertyVal(ping, "event_context", JSON.stringify({ foo: "bar" })); + }); + it("should not stringify event_context if it is a String", async () => { + const data = { + action: "asrouter_undesired_event", + event: "UNDESIRED_EVENT", + event_context: "foo", + }; + const action = ac.ASRouterUserEvent(data); + const { ping } = await instance.createASRouterEvent(action); + + assert.propertyVal(ping, "event_context", "foo"); + }); + }); + describe("#sendEventPing", () => { + it("should call sendStructuredIngestionEvent", async () => { + const data = { + action: "activity_stream_user_event", + event: "CLICK", + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.sendEventPing(data); + + const expectedPayload = { + client_id: FAKE_TELEMETRY_ID, + event: "CLICK", + browser_session_id: "fake_session_id", + }; + assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload); + }); + it("should stringify value if it is an Object", async () => { + const data = { + action: "activity_stream_user_event", + event: "CLICK", + value: { foo: "bar" }, + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.sendEventPing(data); + + const expectedPayload = { + client_id: FAKE_TELEMETRY_ID, + event: "CLICK", + browser_session_id: "fake_session_id", + value: JSON.stringify({ foo: "bar" }), + }; + assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload); + }); + }); + describe("#sendSessionPing", () => { + it("should call sendStructuredIngestionEvent", async () => { + const data = { + action: "activity_stream_session", + page: "about:home", + session_duration: 10000, + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.sendSessionPing(data); + + const expectedPayload = { + client_id: FAKE_TELEMETRY_ID, + page: "about:home", + session_duration: 10000, + }; + assert.calledWith(instance.sendStructuredIngestionEvent, expectedPayload); + }); + }); + describe("#sendEvent", () => { + it("should call sendEventPing on activity_stream_user_event", () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + const event = { action: "activity_stream_user_event" }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendEventPing"); + + instance.sendEvent(event); + + assert.calledOnce(instance.sendEventPing); + }); + it("should call sendSessionPing on activity_stream_session", () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + const event = { action: "activity_stream_session" }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendSessionPing"); + + instance.sendEvent(event); + + assert.calledOnce(instance.sendSessionPing); + }); + }); + describe("#sendUTEvent", () => { + it("should call the UT event function passed in", async () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true); + const event = {}; + instance = new TelemetryFeed(); + sandbox.stub(instance.utEvents, "sendUserEvent"); + + await instance.sendUTEvent(event, instance.utEvents.sendUserEvent); + + assert.calledWith(instance.utEvents.sendUserEvent, event); + }); + }); + describe("#sendStructuredIngestionEvent", () => { + it("should call PingCentre sendStructuredIngestionPing", async () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + const event = {}; + instance = new TelemetryFeed(); + sandbox.stub(instance.pingCentre, "sendStructuredIngestionPing"); + + await instance.sendStructuredIngestionEvent( + event, + "http://foo.com/base/" + ); + + assert.calledWith(instance.pingCentre.sendStructuredIngestionPing, event); + }); + }); + describe("#setLoadTriggerInfo", () => { + it("should call saveSessionPerfData w/load_trigger_{ts,type} data", () => { + sandbox.stub(global.Cu, "now").returns(12345); + + globals.set("ChromeUtils", { + addProfilerMarker: sandbox.stub(), + }); + + instance.browserOpenNewtabStart(); + + const stub = sandbox.stub(instance, "saveSessionPerfData"); + instance.addSession("port123"); + + instance.setLoadTriggerInfo("port123"); + + assert.calledWith(stub, "port123", { + load_trigger_ts: 1588010448000 + 12345, + load_trigger_type: "menu_plus_or_keyboard", + }); + }); + + it("should not call saveSessionPerfData when getting mark throws", () => { + const stub = sandbox.stub(instance, "saveSessionPerfData"); + instance.addSession("port123"); + + instance.setLoadTriggerInfo("port123"); + + assert.notCalled(stub); + }); + }); + + describe("#saveSessionPerfData", () => { + it("should update the given session with the given data", () => { + instance.addSession("port123"); + assert.notProperty(instance.sessions.get("port123"), "fake_ts"); + const data = { fake_ts: 456, other_fake_ts: 789 }; + + instance.saveSessionPerfData("port123", data); + + assert.include(instance.sessions.get("port123").perf, data); + }); + + it("should call setLoadTriggerInfo if data has visibility_event_rcvd_ts", () => { + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123"); + const data = { visibility_event_rcvd_ts: 444455 }; + + instance.saveSessionPerfData("port123", data); + + assert.calledOnce(instance.setLoadTriggerInfo); + assert.calledWithExactly(instance.setLoadTriggerInfo, "port123"); + assert.include(instance.sessions.get("port123").perf, data); + }); + + it("shouldn't call setLoadTriggerInfo if data has no visibility_event_rcvd_ts", () => { + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123"); + + instance.saveSessionPerfData("port123", { monkeys_ts: 444455 }); + + assert.notCalled(instance.setLoadTriggerInfo); + }); + + it("should not call setLoadTriggerInfo when url is about:home", () => { + sandbox.stub(instance, "setLoadTriggerInfo"); + instance.addSession("port123", "about:home"); + const data = { visibility_event_rcvd_ts: 444455 }; + + instance.saveSessionPerfData("port123", data); + + assert.notCalled(instance.setLoadTriggerInfo); + }); + + it("should call maybeRecordTopsitesPainted when url is about:home and topsites_first_painted_ts is given", () => { + const topsites_first_painted_ts = 44455; + const data = { topsites_first_painted_ts }; + const spy = sandbox.spy(); + + sandbox.stub(Services.prefs, "getIntPref").returns(1); + globals.set("AboutNewTab", { + maybeRecordTopsitesPainted: spy, + }); + instance.addSession("port123", "about:home"); + instance.saveSessionPerfData("port123", data); + + assert.calledOnce(spy); + assert.calledWith(spy, topsites_first_painted_ts); + }); + it("should record a Glean newtab.opened event with the correct visit_id when visibility event received", () => { + const session_id = "decafc0ffee"; + const page = "about:newtab"; + const session = { page, perf: {}, session_id }; + const data = { visibility_event_rcvd_ts: 444455 }; + sandbox.stub(instance.sessions, "get").returns(session); + + sandbox.spy(Glean.newtab.opened, "record"); + instance.saveSessionPerfData("port123", data); + + assert.calledOnce(Glean.newtab.opened.record); + assert.deepEqual(Glean.newtab.opened.record.firstCall.args[0], { + newtab_visit_id: session_id, + source: page, + }); + }); + }); + describe("#uninit", () => { + it("should call .pingCentre.uninit", () => { + const stub = sandbox.stub(instance.pingCentre, "uninit"); + + instance.uninit(); + + assert.calledOnce(stub); + }); + it("should call .utEvents.uninit", () => { + const stub = sandbox.stub(instance.utEvents, "uninit"); + + instance.uninit(); + + assert.calledOnce(stub); + }); + it("should make this.browserOpenNewtabStart() stop observing browser-open-newtab-start and domwindowopened", async () => { + await instance.init(); + sandbox.spy(Services.obs, "removeObserver"); + sandbox.stub(instance.pingCentre, "uninit"); + + await instance.uninit(); + + assert.calledTwice(Services.obs.removeObserver); + assert.calledWithExactly( + Services.obs.removeObserver, + instance.browserOpenNewtabStart, + "browser-open-newtab-start" + ); + assert.calledWithExactly( + Services.obs.removeObserver, + instance._addWindowListeners, + "domwindowopened" + ); + }); + }); + describe("#onAction", () => { + beforeEach(() => { + FAKE_GLOBAL_PREFS.clear(); + }); + it("should call .init() on an INIT action", () => { + const init = sandbox.stub(instance, "init"); + const sendPageTakeoverData = sandbox.stub( + instance, + "sendPageTakeoverData" + ); + + instance.onAction({ type: at.INIT }); + + assert.calledOnce(init); + assert.calledOnce(sendPageTakeoverData); + }); + it("should call .uninit() on an UNINIT action", () => { + const stub = sandbox.stub(instance, "uninit"); + + instance.onAction({ type: at.UNINIT }); + + assert.calledOnce(stub); + }); + it("should call .handleNewTabInit on a NEW_TAB_INIT action", () => { + sandbox.spy(instance, "handleNewTabInit"); + + instance.onAction( + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser }, + }) + ); + + assert.calledOnce(instance.handleNewTabInit); + }); + it("should call .addSession() on a NEW_TAB_INIT action", () => { + const stub = sandbox.stub(instance, "addSession").returns({ perf: {} }); + sandbox.stub(instance, "setLoadTriggerInfo"); + + instance.onAction( + ac.AlsoToMain( + { + type: at.NEW_TAB_INIT, + data: { url: "about:monkeys", browser }, + }, + "port123" + ) + ); + + assert.calledOnce(stub); + assert.calledWith(stub, "port123", "about:monkeys"); + }); + it("should call .endSession() on a NEW_TAB_UNLOAD action", () => { + const stub = sandbox.stub(instance, "endSession"); + + instance.onAction(ac.AlsoToMain({ type: at.NEW_TAB_UNLOAD }, "port123")); + + assert.calledWith(stub, "port123"); + }); + it("should call .saveSessionPerfData on SAVE_SESSION_PERF_DATA", () => { + const stub = sandbox.stub(instance, "saveSessionPerfData"); + const data = { some_ts: 10 }; + const action = { type: at.SAVE_SESSION_PERF_DATA, data }; + + instance.onAction(ac.AlsoToMain(action, "port123")); + + assert.calledWith(stub, "port123", data); + }); + it("should send an event on a TELEMETRY_USER_EVENT action", () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true); + instance = new TelemetryFeed(); + + const sendEvent = sandbox.stub(instance, "sendEvent"); + const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent"); + const eventCreator = sandbox.stub(instance, "createUserEvent"); + const action = { type: at.TELEMETRY_USER_EVENT }; + + instance.onAction(action); + + assert.calledWith(eventCreator, action); + assert.calledWith(sendEvent, eventCreator.returnValue); + assert.calledWith(utSendUserEvent, eventCreator.returnValue); + }); + it("should send an event on a DISCOVERY_STREAM_USER_EVENT action", () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true); + instance = new TelemetryFeed(); + + const sendEvent = sandbox.stub(instance, "sendEvent"); + const utSendUserEvent = sandbox.stub(instance.utEvents, "sendUserEvent"); + const eventCreator = sandbox.stub(instance, "createUserEvent"); + const action = { type: at.DISCOVERY_STREAM_USER_EVENT }; + + instance.onAction(action); + + assert.calledWith(eventCreator, { + ...action, + data: { + value: { + pocket_logged_in_status: true, + }, + }, + }); + assert.calledWith(sendEvent, eventCreator.returnValue); + assert.calledWith(utSendUserEvent, eventCreator.returnValue); + }); + describe("should call handleASRouterUserEvent on x action", () => { + const actions = [ + at.AS_ROUTER_TELEMETRY_USER_EVENT, + msg.TOOLBAR_BADGE_TELEMETRY, + msg.TOOLBAR_PANEL_TELEMETRY, + msg.MOMENTS_PAGE_TELEMETRY, + msg.DOORHANGER_TELEMETRY, + ]; + actions.forEach(type => { + it(`${type} action`, () => { + FAKE_GLOBAL_PREFS.set(TELEMETRY_PREF, true); + FAKE_GLOBAL_PREFS.set(EVENTS_TELEMETRY_PREF, true); + instance = new TelemetryFeed(); + + const eventHandler = sandbox.spy(instance, "handleASRouterUserEvent"); + const action = { + type, + data: { event: "CLICK" }, + }; + + instance.onAction(action); + + assert.calledWith(eventHandler, action); + }); + }); + }); + it("should send an event on a TELEMETRY_IMPRESSION_STATS action", () => { + const sendEvent = sandbox.stub(instance, "sendStructuredIngestionEvent"); + const eventCreator = sandbox.stub(instance, "createImpressionStats"); + const tiles = [{ id: 10001 }, { id: 10002 }, { id: 10003 }]; + const action = ac.ImpressionStats({ source: "POCKET", tiles }); + + instance.onAction(action); + + assert.calledWith( + eventCreator, + au.getPortIdOfSender(action), + action.data + ); + assert.calledWith(sendEvent, eventCreator.returnValue); + }); + it("should call .handleDiscoveryStreamImpressionStats on a DISCOVERY_STREAM_IMPRESSION_STATS action", () => { + const session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + const data = { source: "foo", tiles: [{ id: 1 }] }; + const action = { type: at.DISCOVERY_STREAM_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleDiscoveryStreamImpressionStats"); + + instance.onAction(ac.AlsoToMain(action, "port123")); + + assert.calledWith( + instance.handleDiscoveryStreamImpressionStats, + "port123", + data + ); + }); + it("should call .handleDiscoveryStreamLoadedContent on a DISCOVERY_STREAM_LOADED_CONTENT action", () => { + const session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + const data = { source: "foo", tiles: [{ id: 1 }] }; + const action = { type: at.DISCOVERY_STREAM_LOADED_CONTENT, data }; + sandbox.spy(instance, "handleDiscoveryStreamLoadedContent"); + + instance.onAction(ac.AlsoToMain(action, "port123")); + + assert.calledWith( + instance.handleDiscoveryStreamLoadedContent, + "port123", + data + ); + }); + it("should call .handleTopSitesSponsoredImpressionStats on a TOP_SITES_SPONSORED_IMPRESSION_STATS action", () => { + const session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + const data = { type: "impression", tile_id: 42, position: 1 }; + const action = { type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleTopSitesSponsoredImpressionStats"); + + instance.onAction(ac.AlsoToMain(action)); + + assert.calledOnce(instance.handleTopSitesSponsoredImpressionStats); + assert.deepEqual( + instance.handleTopSitesSponsoredImpressionStats.firstCall.args[0].data, + data + ); + }); + }); + it("should call .handleTopSitesOrganicImpressionStats on a TOP_SITES_ORGANIC_IMPRESSION_STATS action", () => { + const session = {}; + sandbox.stub(instance.sessions, "get").returns(session); + const data = { type: "impression", position: 1 }; + const action = { type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, data }; + sandbox.spy(instance, "handleTopSitesOrganicImpressionStats"); + + instance.onAction(ac.AlsoToMain(action)); + + assert.calledOnce(instance.handleTopSitesOrganicImpressionStats); + assert.deepEqual( + instance.handleTopSitesOrganicImpressionStats.firstCall.args[0].data, + data + ); + }); + describe("#handleNewTabInit", () => { + it("should set the session as preloaded if the browser is preloaded", () => { + const session = { perf: {} }; + let preloadedBrowser = { + getAttribute() { + return "preloaded"; + }, + }; + sandbox.stub(instance, "addSession").returns(session); + + instance.onAction( + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser: preloadedBrowser }, + }) + ); + + assert.ok(session.perf.is_preloaded); + }); + it("should set the session as non-preloaded if the browser is non-preloaded", () => { + const session = { perf: {} }; + let nonPreloadedBrowser = { + getAttribute() { + return ""; + }, + }; + sandbox.stub(instance, "addSession").returns(session); + + instance.onAction( + ac.AlsoToMain({ + type: at.NEW_TAB_INIT, + data: { url: "about:newtab", browser: nonPreloadedBrowser }, + }) + ); + + assert.ok(!session.perf.is_preloaded); + }); + }); + describe("#SendASRouterUndesiredEvent", () => { + it("should call handleASRouterUserEvent", () => { + let stub = sandbox.stub(instance, "handleASRouterUserEvent"); + + instance.SendASRouterUndesiredEvent({ foo: "bar" }); + + assert.calledOnce(stub); + let [payload] = stub.firstCall.args; + assert.propertyVal(payload.data, "action", "asrouter_undesired_event"); + assert.propertyVal(payload.data, "foo", "bar"); + }); + }); + describe("#sendPageTakeoverData", () => { + let fakePrefs = { "browser.newtabpage.enabled": true }; + + beforeEach(() => { + globals.set( + "Services", + Object.assign({}, Services, { + prefs: { getBoolPref: key => fakePrefs[key] }, + }) + ); + // Services.prefs = {getBoolPref: key => fakePrefs[key]}; + sandbox.spy(Glean.newtab.newtabCategory, "set"); + sandbox.spy(Glean.newtab.homepageCategory, "set"); + }); + it("should send correct event data for about:home set to custom URL", async () => { + fakeHomePageUrl = "https://searchprovider.com"; + instance._prefs.set(TELEMETRY_PREF, true); + instance._classifySite = () => Promise.resolve("other"); + const sendEvent = sandbox.stub(instance, "sendEvent"); + + await instance.sendPageTakeoverData(); + assert.calledOnce(sendEvent); + assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA"); + assert.deepEqual(sendEvent.firstCall.args[0].value, { + home_url_category: "other", + }); + assert.validate(sendEvent.firstCall.args[0], UserEventPing); + assert.calledOnce(Glean.newtab.homepageCategory.set); + assert.calledWith(Glean.newtab.homepageCategory.set, "other"); + }); + it("should send correct event data for about:newtab set to custom URL", async () => { + globals.set("AboutNewTab", { + newTabURLOverridden: true, + newTabURL: "https://searchprovider.com", + }); + instance._prefs.set(TELEMETRY_PREF, true); + instance._classifySite = () => Promise.resolve("other"); + const sendEvent = sandbox.stub(instance, "sendEvent"); + + await instance.sendPageTakeoverData(); + assert.calledOnce(sendEvent); + assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA"); + assert.deepEqual(sendEvent.firstCall.args[0].value, { + newtab_url_category: "other", + }); + assert.validate(sendEvent.firstCall.args[0], UserEventPing); + assert.calledOnce(Glean.newtab.newtabCategory.set); + assert.calledWith(Glean.newtab.newtabCategory.set, "other"); + }); + it("should not send an event if neither about:{home,newtab} are set to custom URL", async () => { + instance._prefs.set(TELEMETRY_PREF, true); + const sendEvent = sandbox.stub(instance, "sendEvent"); + + await instance.sendPageTakeoverData(); + assert.notCalled(sendEvent); + assert.calledOnce(Glean.newtab.newtabCategory.set); + assert.calledOnce(Glean.newtab.homepageCategory.set); + assert.calledWith(Glean.newtab.newtabCategory.set, "enabled"); + assert.calledWith(Glean.newtab.homepageCategory.set, "enabled"); + }); + it("should send home_extension_id and newtab_extension_id when appropriate", async () => { + const ID = "{abc-foo-bar}"; + fakeExtensionSettingsStore.getSetting = () => ({ id: ID }); + instance._prefs.set(TELEMETRY_PREF, true); + instance._classifySite = () => Promise.resolve("other"); + const sendEvent = sandbox.stub(instance, "sendEvent"); + + await instance.sendPageTakeoverData(); + assert.calledOnce(sendEvent); + assert.equal(sendEvent.firstCall.args[0].event, "PAGE_TAKEOVER_DATA"); + assert.deepEqual(sendEvent.firstCall.args[0].value, { + home_extension_id: ID, + newtab_extension_id: ID, + }); + assert.validate(sendEvent.firstCall.args[0], UserEventPing); + assert.calledOnce(Glean.newtab.newtabCategory.set); + assert.calledOnce(Glean.newtab.homepageCategory.set); + assert.equal(Glean.newtab.newtabCategory.set.args[0], "extension"); + assert.equal(Glean.newtab.homepageCategory.set.args[0], "extension"); + }); + it("instruments when newtab is disabled", async () => { + instance._prefs.set(TELEMETRY_PREF, true); + fakePrefs["browser.newtabpage.enabled"] = false; + await instance.sendPageTakeoverData(); + assert.calledOnce(Glean.newtab.newtabCategory.set); + assert.calledWith(Glean.newtab.newtabCategory.set, "disabled"); + }); + it("instruments when homepage is disabled", async () => { + instance._prefs.set(TELEMETRY_PREF, true); + fakeHomePage.overridden = true; + await instance.sendPageTakeoverData(); + assert.calledOnce(Glean.newtab.homepageCategory.set); + assert.calledWith(Glean.newtab.homepageCategory.set, "disabled"); + }); + it("should send a 'newtab' ping", async () => { + instance._prefs.set(TELEMETRY_PREF, true); + sandbox.spy(GleanPings.newtab, "submit"); + await instance.sendPageTakeoverData(); + assert.calledOnce(GleanPings.newtab.submit); + assert.calledWithExactly(GleanPings.newtab.submit, "component_init"); + }); + }); + describe("#sendDiscoveryStreamImpressions", () => { + it("should not send impression pings if there is no impression data", () => { + const spy = sandbox.spy(instance, "sendEvent"); + const session = {}; + instance.sendDiscoveryStreamImpressions("foo", session); + + assert.notCalled(spy); + }); + it("should send impression pings if there is impression data", () => { + const spy = sandbox.spy(instance, "sendStructuredIngestionEvent"); + const session = { + impressionSets: { + source_foo: [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ], + source_bar: [ + { id: 3, pos: 0 }, + { id: 4, pos: 1 }, + ], + }, + }; + instance.sendDiscoveryStreamImpressions("foo", session); + + assert.calledTwice(spy); + }); + }); + describe("#sendDiscoveryStreamLoadedContent", () => { + it("should not send loaded content pings if there is no loaded content data", () => { + const spy = sandbox.spy(instance, "sendEvent"); + const session = {}; + instance.sendDiscoveryStreamLoadedContent("foo", session); + + assert.notCalled(spy); + }); + it("should send loaded content pings if there is loaded content data", () => { + const spy = sandbox.spy(instance, "sendStructuredIngestionEvent"); + const session = { + loadedContentSets: { + source_foo: [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ], + source_bar: [ + { id: 3, pos: 0 }, + { id: 4, pos: 1 }, + ], + }, + }; + instance.sendDiscoveryStreamLoadedContent("foo", session); + + assert.calledTwice(spy); + + let [payload] = spy.firstCall.args; + let sources = new Set([]); + sources.add(payload.source); + assert.equal(payload.loaded, 2); + assert.deepEqual( + payload.tiles, + session.loadedContentSets[payload.source] + ); + + [payload] = spy.secondCall.args; + sources.add(payload.source); + assert.equal(payload.loaded, 2); + assert.deepEqual( + payload.tiles, + session.loadedContentSets[payload.source] + ); + + assert.deepEqual(sources, new Set(["source_foo", "source_bar"])); + }); + }); + describe("#handleDiscoveryStreamImpressionStats", () => { + it("should throw for a missing session", () => { + assert.throws(() => { + instance.handleDiscoveryStreamImpressionStats("a_missing_port", {}); + }, "Session does not exist."); + }); + it("should store impression to impressionSets", () => { + const session = instance.addSession("new_session", "about:newtab"); + instance.handleDiscoveryStreamImpressionStats("new_session", { + source: "foo", + tiles: [{ id: 1, pos: 0 }], + window_inner_width: 1000, + window_inner_height: 900, + }); + + assert.equal(Object.keys(session.impressionSets).length, 1); + assert.deepEqual(session.impressionSets.foo, { + tiles: [{ id: 1, pos: 0 }], + window_inner_width: 1000, + window_inner_height: 900, + }); + + // Add another ping with the same source + instance.handleDiscoveryStreamImpressionStats("new_session", { + source: "foo", + tiles: [{ id: 2, pos: 1 }], + window_inner_width: 1000, + window_inner_height: 900, + }); + + assert.deepEqual(session.impressionSets.foo, { + tiles: [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ], + window_inner_width: 1000, + window_inner_height: 900, + }); + + // Add another ping with a different source + instance.handleDiscoveryStreamImpressionStats("new_session", { + source: "bar", + tiles: [{ id: 3, pos: 2 }], + window_inner_width: 1000, + window_inner_height: 900, + }); + + assert.equal(Object.keys(session.impressionSets).length, 2); + assert.deepEqual(session.impressionSets.foo, { + tiles: [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ], + window_inner_width: 1000, + window_inner_height: 900, + }); + assert.deepEqual(session.impressionSets.bar, { + tiles: [{ id: 3, pos: 2 }], + window_inner_width: 1000, + window_inner_height: 900, + }); + }); + it("should instrument pocket impressions", () => { + const session_id = "1337cafe"; + const pos1 = 1; + const pos2 = 4; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.impression, "record"); + + instance.handleDiscoveryStreamImpressionStats("_", { + source: "foo", + tiles: [ + { id: 1, pos: pos1, type: "organic" }, + { id: 2, pos: pos2, type: "spoc" }, + ], + window_inner_width: 1000, + window_inner_height: 900, + }); + + assert.calledTwice(Glean.pocket.impression.record); + assert.deepEqual(Glean.pocket.impression.record.firstCall.args[0], { + newtab_visit_id: session_id, + is_sponsored: false, + position: pos1, + }); + assert.deepEqual(Glean.pocket.impression.record.secondCall.args[0], { + newtab_visit_id: session_id, + is_sponsored: true, + position: pos2, + }); + }); + }); + describe("#handleDiscoveryStreamLoadedContent", () => { + it("should throw for a missing session", () => { + assert.throws(() => { + instance.handleDiscoveryStreamLoadedContent("a_missing_port", {}); + }, "Session does not exist."); + }); + it("should store loaded content to loadedContentSets", () => { + const session = instance.addSession("new_session", "about:newtab"); + instance.handleDiscoveryStreamLoadedContent("new_session", { + source: "foo", + tiles: [{ id: 1, pos: 0 }], + }); + + assert.equal(Object.keys(session.loadedContentSets).length, 1); + assert.deepEqual(session.loadedContentSets.foo, [{ id: 1, pos: 0 }]); + + // Add another ping with the same source + instance.handleDiscoveryStreamLoadedContent("new_session", { + source: "foo", + tiles: [{ id: 2, pos: 1 }], + }); + + assert.deepEqual(session.loadedContentSets.foo, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ]); + + // Add another ping with a different source + instance.handleDiscoveryStreamLoadedContent("new_session", { + source: "bar", + tiles: [{ id: 3, pos: 2 }], + }); + + assert.equal(Object.keys(session.loadedContentSets).length, 2); + assert.deepEqual(session.loadedContentSets.foo, [ + { id: 1, pos: 0 }, + { id: 2, pos: 1 }, + ]); + assert.deepEqual(session.loadedContentSets.bar, [{ id: 3, pos: 2 }]); + }); + }); + describe("#_generateStructuredIngestionEndpoint", () => { + it("should generate a valid endpoint", () => { + const fakeEndpoint = "http://fakeendpoint.com/base/"; + const fakeUUID = "{34f24486-f01a-9749-9c5b-21476af1fa77}"; + const fakeUUIDWithoutBraces = fakeUUID.substring(1, fakeUUID.length - 1); + FAKE_GLOBAL_PREFS.set(STRUCTURED_INGESTION_ENDPOINT_PREF, fakeEndpoint); + sandbox.stub(Services.uuid, "generateUUID").returns(fakeUUID); + const feed = new TelemetryFeed(); + const url = feed._generateStructuredIngestionEndpoint( + "testNameSpace", + "testPingType", + "1" + ); + + assert.equal( + url, + `${fakeEndpoint}/testNameSpace/testPingType/1/${fakeUUIDWithoutBraces}` + ); + }); + }); + describe("#handleASRouterUserEvent", () => { + it("should call sendStructuredIngestionEvent on known pingTypes", async () => { + const data = { + action: "onboarding_user_event", + event: "IMPRESSION", + message_id: "12345", + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.handleASRouterUserEvent({ data }); + + assert.calledOnce(instance.sendStructuredIngestionEvent); + }); + it("should call submitGleanPingForPing on known pingTypes when telemetry is enabled", async () => { + const data = { + action: "onboarding_user_event", + event: "IMPRESSION", + message_id: "12345", + }; + instance = new TelemetryFeed(); + instance._prefs.set(TELEMETRY_PREF, true); + sandbox.spy( + global.AboutWelcomeTelemetry.prototype, + "submitGleanPingForPing" + ); + + await instance.handleASRouterUserEvent({ data }); + + assert.calledOnce( + global.AboutWelcomeTelemetry.prototype.submitGleanPingForPing + ); + }); + it("should console.error and not submit pings on unknown pingTypes", async () => { + const data = { + action: "unknown_event", + event: "IMPRESSION", + message_id: "12345", + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.handleASRouterUserEvent({ data }); + + assert.calledOnce(global.console.error); + assert.notCalled(instance.sendStructuredIngestionEvent); + }); + }); + describe("#isInCFRCohort", () => { + it("should return false if there is no CFR experiment registered", () => { + assert.ok(!instance.isInCFRCohort); + }); + it("should return true if there is a CFR experiment registered", () => { + sandbox.stub(ExperimentAPI, "getExperimentMetaData").returns({ + slug: "SOME-CFR-EXP", + }); + + assert.ok(instance.isInCFRCohort); + assert.propertyVal( + ExperimentAPI.getExperimentMetaData.firstCall.args[0], + "featureId", + "cfr" + ); + }); + }); + describe("#handleTopSitesSponsoredImpressionStats", () => { + it("should call sendStructuredIngestionEvent on an impression event", async () => { + const data = { + type: "impression", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + sandbox.spy(Services.telemetry, "keyedScalarAdd"); + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + + // Scalar should be added + assert.calledOnce(Services.telemetry.keyedScalarAdd); + assert.calledWith( + Services.telemetry.keyedScalarAdd, + "contextual.services.topsites.impression", + "newtab_1", + 1 + ); + + assert.calledOnce(instance.sendStructuredIngestionEvent); + + const { args } = instance.sendStructuredIngestionEvent.firstCall; + // payload + assert.deepEqual(args[0], { + context_id: FAKE_UUID, + tile_id: 42, + source: "newtab", + position: 1, + reporting_url: "https://test.reporting.net/", + }); + // namespace + assert.equal(args[1], "contextual-services"); + // docType + assert.equal(args[2], "topsites-impression"); + // version + assert.equal(args[3], "1"); + }); + it("should call sendStructuredIngestionEvent on a click event", async () => { + const data = { + type: "click", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + sandbox.spy(Services.telemetry, "keyedScalarAdd"); + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + + // Scalar should be added + assert.calledOnce(Services.telemetry.keyedScalarAdd); + assert.calledWith( + Services.telemetry.keyedScalarAdd, + "contextual.services.topsites.click", + "newtab_1", + 1 + ); + + assert.calledOnce(instance.sendStructuredIngestionEvent); + + const { args } = instance.sendStructuredIngestionEvent.firstCall; + // payload + assert.deepEqual(args[0], { + context_id: FAKE_UUID, + tile_id: 42, + source: "newtab", + position: 1, + reporting_url: "https://test.reporting.net/", + }); + // namespace + assert.equal(args[1], "contextual-services"); + // docType + assert.equal(args[2], "topsites-click"); + // version + assert.equal(args[3], "1"); + }); + it("should record a Glean topsites.impression event on an impression event", async () => { + const data = { + type: "impression", + tile_id: 42, + source: "newtab", + position: 1, + reporting_url: "https://test.reporting.net/", + advertiser: "adnoid ads", + }; + instance = new TelemetryFeed(); + const session_id = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.topsites.impression, "record"); + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + + // Event should be recorded + assert.calledOnce(Glean.topsites.impression.record); + assert.calledWith(Glean.topsites.impression.record, { + advertiser_name: "adnoid ads", + tile_id: "42", + newtab_visit_id: session_id, + is_sponsored: true, + position: 1, + }); + }); + it("should record a Glean topsites.click event on a click event", async () => { + const data = { + type: "click", + advertiser: "test advertiser", + tile_id: 42, + source: "newtab", + position: 0, + reporting_url: "https://test.reporting.net/", + }; + instance = new TelemetryFeed(); + const session_id = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.topsites.click, "record"); + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + + // Event should be recorded + assert.calledOnce(Glean.topsites.click.record); + assert.calledWith(Glean.topsites.click.record, { + advertiser_name: "test advertiser", + tile_id: "42", + newtab_visit_id: session_id, + is_sponsored: true, + position: 0, + }); + }); + it("should console.error on unknown pingTypes", async () => { + const data = { type: "unknown_type" }; + instance = new TelemetryFeed(); + sandbox.spy(instance, "sendStructuredIngestionEvent"); + + await instance.handleTopSitesSponsoredImpressionStats({ data }); + + assert.calledOnce(global.console.error); + assert.notCalled(instance.sendStructuredIngestionEvent); + }); + }); + describe("#handleTopSitesOrganicImpressionStats", () => { + it("should record a Glean topsites.impression event on an impression event", async () => { + const data = { + type: "impression", + source: "newtab", + position: 0, + }; + instance = new TelemetryFeed(); + const session_id = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.topsites.impression, "record"); + + await instance.handleTopSitesOrganicImpressionStats({ data }); + + assert.calledOnce(Glean.topsites.impression.record); + assert.calledWith(Glean.topsites.impression.record, { + newtab_visit_id: session_id, + is_sponsored: false, + position: 0, + }); + }); + it("should record a Glean topsites.click event on a click event", async () => { + const data = { + type: "click", + source: "newtab", + position: 0, + }; + instance = new TelemetryFeed(); + const session_id = "decafc0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.topsites.click, "record"); + + await instance.handleTopSitesOrganicImpressionStats({ data }); + + assert.calledOnce(Glean.topsites.click.record); + assert.calledWith(Glean.topsites.click.record, { + newtab_visit_id: session_id, + is_sponsored: false, + position: 0, + }); + }); + }); + describe("#handleDiscoveryStreamUserEvent", () => { + it("correctly handles action with no `data`", () => { + const action = ac.DiscoveryStreamUserEvent(); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + sandbox.spy(Glean.pocket.click, "record"); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.notCalled(Glean.pocket.topicClick.record); + assert.notCalled(Glean.pocket.click.record); + assert.notCalled(Glean.pocket.save.record); + }); + it("correctly handles CLICK data with no value", () => { + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.topicClick.record); + assert.calledWith(Glean.pocket.topicClick.record, { + newtab_visit_id: session_id, + topic: undefined, + }); + }); + it("correctly handles non-POPULAR_TOPICS CLICK data with no value", () => { + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + sandbox.spy(Glean.pocket.click, "record"); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.notCalled(Glean.pocket.topicClick.record); + assert.notCalled(Glean.pocket.click.record); + assert.notCalled(Glean.pocket.save.record); + }); + it("correctly handles CLICK data with non-POPULAR_TOPICS source", () => { + const topic = "atopic"; + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + value: { + card_type: "topics_widget", + topic, + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.topicClick.record); + assert.calledWith(Glean.pocket.topicClick.record, { + newtab_visit_id: session_id, + topic, + }); + }); + it("doesn't instrument a CLICK without a card_type", () => { + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "not-POPULAR_TOPICS", + value: { + card_type: "not spoc, organic, or topics_widget", + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + sandbox.spy(Glean.pocket.click, "record"); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.notCalled(Glean.pocket.topicClick.record); + assert.notCalled(Glean.pocket.click.record); + assert.notCalled(Glean.pocket.save.record); + }); + it("instruments a popular topic click", () => { + const topic = "entertainment"; + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + value: { + card_type: "topics_widget", + topic, + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.topicClick, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.topicClick.record); + assert.calledWith(Glean.pocket.topicClick.record, { + newtab_visit_id: session_id, + topic, + }); + }); + it("instruments an organic top stories click", () => { + const action_position = 42; + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + action_position, + value: { + card_type: "organic", + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.click, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.click.record); + assert.calledWith(Glean.pocket.click.record, { + newtab_visit_id: session_id, + is_sponsored: false, + position: action_position, + }); + }); + it("instruments a sponsored top stories click", () => { + const action_position = 42; + const action = ac.DiscoveryStreamUserEvent({ + event: "CLICK", + action_position, + value: { + card_type: "spoc", + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.click, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.click.record); + assert.calledWith(Glean.pocket.click.record, { + newtab_visit_id: session_id, + is_sponsored: true, + position: action_position, + }); + }); + it("instruments a save of an organic top story", () => { + const action_position = 42; + const action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position, + value: { + card_type: "organic", + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.save.record); + assert.calledWith(Glean.pocket.save.record, { + newtab_visit_id: session_id, + is_sponsored: false, + position: action_position, + }); + }); + it("instruments a save of a sponsored top story", () => { + const action_position = 42; + const action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position, + value: { + card_type: "spoc", + }, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.save.record); + assert.calledWith(Glean.pocket.save.record, { + newtab_visit_id: session_id, + is_sponsored: true, + position: action_position, + }); + }); + it("instruments a save of a sponsored top story, without `value`", () => { + const action_position = 42; + const action = ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + action_position, + }); + instance = new TelemetryFeed(); + const session_id = "c0ffee"; + sandbox.stub(instance.sessions, "get").returns({ session_id }); + sandbox.spy(Glean.pocket.save, "record"); + + instance.handleDiscoveryStreamUserEvent(action); + + assert.calledOnce(Glean.pocket.save.record); + assert.calledWith(Glean.pocket.save.record, { + newtab_visit_id: session_id, + is_sponsored: false, + position: action_position, + }); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js new file mode 100644 index 0000000000..661a6b7b83 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/TippyTopProvider.test.js @@ -0,0 +1,121 @@ +import { GlobalOverrider } from "test/unit/utils"; +import { TippyTopProvider } from "lib/TippyTopProvider.sys.mjs"; + +describe("TippyTopProvider", () => { + let instance; + let globals; + beforeEach(async () => { + globals = new GlobalOverrider(); + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve([ + { + domains: ["facebook.com"], + image_url: "images/facebook-com.png", + favicon_url: "images/facebook-com.png", + background_color: "#3b5998", + }, + { + domains: ["gmail.com", "mail.google.com"], + image_url: "images/gmail-com.png", + favicon_url: "images/gmail-com.png", + background_color: "#000000", + }, + ]), + }); + instance = new TippyTopProvider(); + await instance.init(); + }); + it("should provide an icon for facebook.com", () => { + const site = instance.processSite({ url: "https://facebook.com" }); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal( + site.smallFavicon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal(site.backgroundColor, "#3b5998"); + }); + it("should provide an icon for www.facebook.com", () => { + const site = instance.processSite({ url: "https://www.facebook.com" }); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal( + site.smallFavicon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal(site.backgroundColor, "#3b5998"); + }); + it("should not provide an icon for other.facebook.com", () => { + const site = instance.processSite({ url: "https://other.facebook.com" }); + assert.isUndefined(site.tippyTopIcon); + }); + it("should provide an icon for other.facebook.com with stripping", () => { + const site = instance.processSite( + { url: "https://other.facebook.com" }, + "*" + ); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + }); + it("should provide an icon for facebook.com/foobar", () => { + const site = instance.processSite({ url: "https://facebook.com/foobar" }); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal( + site.smallFavicon, + "chrome://activity-stream/content/data/content/tippytop/images/facebook-com.png" + ); + assert.equal(site.backgroundColor, "#3b5998"); + }); + it("should provide an icon for gmail.com", () => { + const site = instance.processSite({ url: "https://gmail.com" }); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png" + ); + assert.equal( + site.smallFavicon, + "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png" + ); + assert.equal(site.backgroundColor, "#000000"); + }); + it("should provide an icon for mail.google.com", () => { + const site = instance.processSite({ url: "https://mail.google.com" }); + assert.equal( + site.tippyTopIcon, + "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png" + ); + assert.equal( + site.smallFavicon, + "chrome://activity-stream/content/data/content/tippytop/images/gmail-com.png" + ); + assert.equal(site.backgroundColor, "#000000"); + }); + it("should handle garbage URLs gracefully", () => { + const site = instance.processSite({ url: "garbagejlfkdsa" }); + assert.isUndefined(site.tippyTopIcon); + assert.isUndefined(site.backgroundColor); + }); + it("should handle error when fetching and parsing manifest", async () => { + globals = new GlobalOverrider(); + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + fetchStub.rejects("whaaaa"); + instance = new TippyTopProvider(); + await instance.init(); + instance.processSite({ url: "https://facebook.com" }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js new file mode 100644 index 0000000000..12e70557f6 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js @@ -0,0 +1,649 @@ +import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm"; +import { _ToolbarPanelHub, ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm"; + +describe("ToolbarBadgeHub", () => { + let sandbox; + let instance; + let fakeAddImpression; + let fakeSendTelemetry; + let isBrowserPrivateStub; + let fxaMessage; + let whatsnewMessage; + let fakeElement; + let globals; + let everyWindowStub; + let clearTimeoutStub; + let setTimeoutStub; + let addObserverStub; + let removeObserverStub; + let getStringPrefStub; + let clearUserPrefStub; + let setStringPrefStub; + let requestIdleCallbackStub; + let fakeWindow; + beforeEach(async () => { + globals = new GlobalOverrider(); + sandbox = sinon.createSandbox(); + instance = new _ToolbarBadgeHub(); + fakeAddImpression = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE"); + whatsnewMessage = { + id: `WHATS_NEW_BADGE_71`, + template: "toolbar_badge", + content: { + delay: 1000, + target: "whats-new-menu-button", + action: { id: "show-whatsnew-button" }, + badgeDescription: { string_id: "cfr-badge-reader-label-newfeature" }, + }, + priority: 1, + trigger: { id: "toolbarBadgeUpdate" }, + frequency: { + // Makes it so that we track impressions for this message while at the + // same time it can have unlimited impressions + lifetime: Infinity, + }, + // Never saw this message or saw it in the past 4 days or more recent + targeting: `isWhatsNewPanelEnabled && + (!messageImpressions['WHATS_NEW_BADGE_71'] || + (messageImpressions['WHATS_NEW_BADGE_71']|length >= 1 && + currentDate|date - messageImpressions['WHATS_NEW_BADGE_71'][0] <= 4 * 24 * 3600 * 1000))`, + }; + fakeElement = { + classList: { + add: sandbox.stub(), + remove: sandbox.stub(), + }, + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub(), + addEventListener: sandbox.stub(), + remove: sandbox.stub(), + appendChild: sandbox.stub(), + }; + // Share the same element when selecting child nodes + fakeElement.querySelector.returns(fakeElement); + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + clearTimeoutStub = sandbox.stub(); + setTimeoutStub = sandbox.stub(); + fakeWindow = { + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + gBrowser: { + selectedBrowser: "browser", + }, + }, + }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getStringPrefStub = sandbox.stub(); + clearUserPrefStub = sandbox.stub(); + setStringPrefStub = sandbox.stub(); + requestIdleCallbackStub = sandbox.stub().callsFake(fn => fn()); + globals.set({ + ToolbarPanelHub, + requestIdleCallback: requestIdleCallbackStub, + EveryWindow: everyWindowStub, + PrivateBrowsingUtils: { isBrowserPrivate: isBrowserPrivateStub }, + setTimeout: setTimeoutStub, + clearTimeout: clearTimeoutStub, + Services: { + wm: { + getMostRecentWindow: () => fakeWindow, + }, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getStringPref: getStringPrefStub, + clearUserPref: clearUserPrefStub, + setStringPref: setStringPrefStub, + }, + }, + }); + }); + afterEach(() => { + sandbox.restore(); + globals.restore(); + }); + it("should create an instance", () => { + assert.ok(instance); + }); + describe("#init", () => { + it("should make a single messageRequest on init", async () => { + sandbox.stub(instance, "messageRequest"); + const waitForInitialized = sandbox.stub().resolves(); + + await instance.init(waitForInitialized, {}); + await instance.init(waitForInitialized, {}); + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + + instance.uninit(); + + await instance.init(waitForInitialized, {}); + + assert.calledTwice(instance.messageRequest); + }); + it("should add a pref observer", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + assert.calledOnce(addObserverStub); + assert.calledWithExactly( + addObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("#uninit", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), {}); + }); + it("should clear any setTimeout cbs", async () => { + await instance.init(sandbox.stub().resolves(), {}); + + instance.state.showBadgeTimeoutId = 2; + + instance.uninit(); + + assert.calledOnce(clearTimeoutStub); + assert.calledWithExactly(clearTimeoutStub, 2); + }); + it("should remove the pref observer", () => { + instance.uninit(); + + assert.calledOnce(removeObserverStub); + assert.calledWithExactly( + removeObserverStub, + instance.prefs.WHATSNEW_TOOLBAR_PANEL, + instance + ); + }); + }); + describe("messageRequest", () => { + let handleMessageRequestStub; + beforeEach(() => { + handleMessageRequestStub = sandbox.stub().returns(fxaMessage); + sandbox + .stub(instance, "_handleMessageRequest") + .value(handleMessageRequestStub); + sandbox.stub(instance, "registerBadgeNotificationListener"); + }); + it("should fetch a message with the provided trigger and template", async () => { + await instance.messageRequest({ + triggerId: "trigger", + template: "template", + }); + + assert.calledOnce(handleMessageRequestStub); + assert.calledWithExactly(handleMessageRequestStub, { + triggerId: "trigger", + template: "template", + }); + }); + it("should call addToolbarNotification with browser window and message", async () => { + await instance.messageRequest("trigger"); + + assert.calledOnce(instance.registerBadgeNotificationListener); + assert.calledWithExactly( + instance.registerBadgeNotificationListener, + fxaMessage + ); + }); + it("shouldn't do anything if no message is provided", async () => { + handleMessageRequestStub.resolves(null); + await instance.messageRequest({ triggerId: "trigger" }); + + assert.notCalled(instance.registerBadgeNotificationListener); + }); + it("should record telemetry events", async () => { + const startTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "start" + ); + const finishTelemetryStopwatch = sandbox.stub( + global.TelemetryStopwatch, + "finish" + ); + handleMessageRequestStub.returns(null); + + await instance.messageRequest({ triggerId: "trigger" }); + + assert.calledOnce(startTelemetryStopwatch); + assert.calledWithExactly( + startTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + assert.calledOnce(finishTelemetryStopwatch); + assert.calledWithExactly( + finishTelemetryStopwatch, + "MS_MESSAGE_REQUEST_TIME_MS", + { triggerId: "trigger" } + ); + }); + }); + describe("addToolbarNotification", () => { + let target; + let fakeDocument; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElement), + createElement: sandbox.stub().returns(fakeElement), + l10n: { setAttributes: sandbox.stub() }, + }; + target = { ...fakeWindow, browser: { ownerDocument: fakeDocument } }; + }); + afterEach(() => { + instance.uninit(); + }); + it("shouldn't do anything if target element is not found", () => { + fakeDocument.getElementById.returns(null); + instance.addToolbarNotification(target, fxaMessage); + + assert.notCalled(fakeElement.setAttribute); + }); + it("should target the element specified in the message", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeDocument.getElementById); + assert.calledWithExactly( + fakeDocument.getElementById, + fxaMessage.content.target + ); + }); + it("should show a notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledOnce(fakeElement.setAttribute); + assert.calledWithExactly(fakeElement.setAttribute, "badged", true); + assert.calledWithExactly(fakeElement.classList.add, "feature-callout"); + }); + it("should attach a cb on the notification", () => { + instance.addToolbarNotification(target, fxaMessage); + + assert.calledTwice(fakeElement.addEventListener); + assert.calledWithExactly( + fakeElement.addEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeElement.addEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should execute actions if they exist", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.executeAction); + assert.calledWithExactly(instance.executeAction, { + ...whatsnewMessage.content.action, + message_id: whatsnewMessage.id, + }); + }); + it("should create a description element", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.createElement); + assert.calledWithExactly(fakeDocument.createElement, "span"); + }); + it("should set description id to element and to button", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledWithExactly( + fakeElement.setAttribute, + "id", + "toolbarbutton-notification-description" + ); + assert.calledWithExactly( + fakeElement.setAttribute, + "aria-labelledby", + `toolbarbutton-notification-description ${whatsnewMessage.content.target}` + ); + }); + it("should attach fluent id to description", () => { + sandbox.stub(instance, "executeAction"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(fakeDocument.l10n.setAttributes); + assert.calledWithExactly( + fakeDocument.l10n.setAttributes, + fakeElement, + whatsnewMessage.content.badgeDescription.string_id + ); + }); + it("should add an impression for the message", () => { + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance._addImpression); + assert.calledWithExactly(instance._addImpression, whatsnewMessage); + }); + it("should send an impression ping", async () => { + sandbox.stub(instance, "sendUserEventTelemetry"); + instance.addToolbarNotification(target, whatsnewMessage); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly( + instance.sendUserEventTelemetry, + "IMPRESSION", + whatsnewMessage + ); + }); + }); + describe("registerBadgeNotificationListener", () => { + let msg_no_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + sendTelemetry: fakeSendTelemetry, + }); + sandbox.stub(instance, "addToolbarNotification").returns(fakeElement); + sandbox.stub(instance, "removeToolbarNotification"); + msg_no_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 0, + }, + }; + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a callback that adds/removes the notification", () => { + instance.registerBadgeNotificationListener(msg_no_delay); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + instance.id, + sinon.match.func, + sinon.match.func + ); + + const [, initFn, uninitFn] = + everyWindowStub.registerCallback.firstCall.args; + + initFn(window); + // Test that it doesn't try to add a second notification + initFn(window); + + assert.calledOnce(instance.addToolbarNotification); + assert.calledWithExactly( + instance.addToolbarNotification, + window, + msg_no_delay + ); + + uninitFn(window); + + assert.calledOnce(instance.removeToolbarNotification); + assert.calledWithExactly(instance.removeToolbarNotification, fakeElement); + }); + it("should unregister notifications when forcing a badge via devtools", () => { + instance.registerBadgeNotificationListener(msg_no_delay, { force: true }); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should only call executeAction for 'update_action' messages", () => { + const stub = sandbox.stub(instance, "executeAction"); + const updateActionMsg = { ...msg_no_delay, template: "update_action" }; + + instance.registerBadgeNotificationListener(updateActionMsg); + + assert.notCalled(everyWindowStub.registerCallback); + assert.calledOnce(stub); + }); + }); + describe("executeAction", () => { + let blockMessageByIdStub; + beforeEach(async () => { + blockMessageByIdStub = sandbox.stub(); + await instance.init(sandbox.stub().resolves(), { + blockMessageById: blockMessageByIdStub, + }); + }); + it("should call ToolbarPanelHub.enableToolbarButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableToolbarButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + it("should call ToolbarPanelHub.enableAppmenuButton", () => { + const stub = sandbox.stub( + _ToolbarPanelHub.prototype, + "enableAppmenuButton" + ); + + instance.executeAction({ id: "show-whatsnew-button" }); + + assert.calledOnce(stub); + }); + }); + describe("removeToolbarNotification", () => { + it("should remove the notification", () => { + instance.removeToolbarNotification(fakeElement); + + assert.calledThrice(fakeElement.removeAttribute); + assert.calledWithExactly(fakeElement.removeAttribute, "badged"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-labelledby"); + assert.calledWithExactly(fakeElement.removeAttribute, "aria-describedby"); + assert.calledOnce(fakeElement.classList.remove); + assert.calledWithExactly(fakeElement.classList.remove, "feature-callout"); + assert.calledOnce(fakeElement.remove); + }); + }); + describe("removeAllNotifications", () => { + let blockMessageByIdStub; + let fakeEvent; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + blockMessageByIdStub = sandbox.stub(); + sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub); + instance.state = { notification: { id: fxaMessage.id } }; + fakeEvent = { target: { removeEventListener: sandbox.stub() } }; + }); + it("should call to block the message", () => { + instance.removeAllNotifications(); + + assert.calledOnce(blockMessageByIdStub); + assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id); + }); + it("should remove the window listener", () => { + instance.removeAllNotifications(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id); + }); + it("should ignore right mouse button (mousedown event)", () => { + fakeEvent.type = "mousedown"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore right mouse button (click event)", () => { + fakeEvent.type = "click"; + fakeEvent.button = 1; // not left click + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should ignore keypresses that are not meant to focus the target", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "\t"; // not enter + + instance.removeAllNotifications(fakeEvent); + + assert.notCalled(fakeEvent.target.removeEventListener); + assert.notCalled(everyWindowStub.unregisterCallback); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + it("should send telemetry", () => { + fakeEvent.type = "click"; + fakeEvent.button = 0; + sandbox.stub(instance, "sendUserEventTelemetry"); + + instance.removeAllNotifications(fakeEvent); + + assert.calledOnce(instance.sendUserEventTelemetry); + assert.calledWithExactly(instance.sendUserEventTelemetry, "CLICK", { + id: "FXA_ACCOUNTS_BADGE", + }); + }); + it("should remove the event listeners after succesfully focusing the element", () => { + fakeEvent.type = "keypress"; + fakeEvent.key = "Enter"; + + instance.removeAllNotifications(fakeEvent); + + assert.calledTwice(fakeEvent.target.removeEventListener); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "mousedown", + instance.removeAllNotifications + ); + assert.calledWithExactly( + fakeEvent.target.removeEventListener, + "keypress", + instance.removeAllNotifications + ); + }); + }); + describe("message with delay", () => { + let msg_with_delay; + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + addImpression: fakeAddImpression, + }); + msg_with_delay = { + ...fxaMessage, + content: { + ...fxaMessage.content, + delay: 500, + }, + }; + sandbox.stub(instance, "registerBadgeToAllWindows"); + }); + afterEach(() => { + instance.uninit(); + }); + it("should register a cb to fire after msg.content.delay ms", () => { + instance.registerBadgeNotificationListener(msg_with_delay); + + assert.calledOnce(setTimeoutStub); + assert.calledWithExactly( + setTimeoutStub, + sinon.match.func, + msg_with_delay.content.delay + ); + + const [cb] = setTimeoutStub.firstCall.args; + + assert.notCalled(instance.registerBadgeToAllWindows); + + cb(); + + assert.calledOnce(instance.registerBadgeToAllWindows); + assert.calledWithExactly( + instance.registerBadgeToAllWindows, + msg_with_delay + ); + // Delayed actions should be executed inside requestIdleCallback + assert.calledOnce(requestIdleCallbackStub); + }); + }); + describe("#sendUserEventTelemetry", () => { + beforeEach(async () => { + await instance.init(sandbox.stub().resolves(), { + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should check for private window and not send", () => { + isBrowserPrivateStub.returns(true); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.notCalled(instance._sendTelemetry); + }); + it("should check for private window and send", () => { + isBrowserPrivateStub.returns(false); + + instance.sendUserEventTelemetry("CLICK", { id: fxaMessage }); + + assert.calledOnce(fakeSendTelemetry); + const [ping] = instance._sendTelemetry.firstCall.args; + assert.propertyVal(ping, "type", "TOOLBAR_BADGE_TELEMETRY"); + assert.propertyVal(ping.data, "event", "CLICK"); + }); + }); + describe("#observe", () => { + it("should make a message request when the whats new pref is changed", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", instance.prefs.WHATSNEW_TOOLBAR_PANEL); + + assert.calledOnce(instance.messageRequest); + assert.calledWithExactly(instance.messageRequest, { + template: "toolbar_badge", + triggerId: "toolbarBadgeUpdate", + }); + }); + it("should not react to other pref changes", () => { + sandbox.stub(instance, "messageRequest"); + + instance.observe("", "", "foo"); + + assert.notCalled(instance.messageRequest); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js new file mode 100644 index 0000000000..36fcc0cbe3 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/ToolbarPanelHub.test.js @@ -0,0 +1,934 @@ +import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm"; +import { GlobalOverrider } from "test/unit/utils"; +import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm"; +import { PanelTestProvider } from "lib/PanelTestProvider.sys.mjs"; + +describe("ToolbarPanelHub", () => { + let globals; + let sandbox; + let instance; + let everyWindowStub; + let preferencesStub; + let fakeDocument; + let fakeWindow; + let fakeElementById; + let fakeElementByTagName; + let createdCustomElements = []; + let eventListeners = {}; + let addObserverStub; + let removeObserverStub; + let getBoolPrefStub; + let setBoolPrefStub; + let waitForInitializedStub; + let isBrowserPrivateStub; + let fakeSendTelemetry; + let getEarliestRecordedDateStub; + let getEventsByDateRangeStub; + let defaultSearchStub; + let scriptloaderStub; + let fakeRemoteL10n; + let getViewNodeStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + globals = new GlobalOverrider(); + instance = new _ToolbarPanelHub(); + waitForInitializedStub = sandbox.stub().resolves(); + fakeElementById = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeElementByTagName = { + setAttribute: sandbox.stub(), + removeAttribute: sandbox.stub(), + querySelector: sandbox.stub().returns(null), + querySelectorAll: sandbox.stub().returns([]), + appendChild: sandbox.stub(), + addEventListener: sandbox.stub(), + hasAttribute: sandbox.stub(), + toggleAttribute: sandbox.stub(), + remove: sandbox.stub(), + removeChild: sandbox.stub(), + }; + fakeDocument = { + getElementById: sandbox.stub().returns(fakeElementById), + getElementsByTagName: sandbox.stub().returns(fakeElementByTagName), + querySelector: sandbox.stub().returns({}), + createElement: tagName => { + const element = { + tagName, + classList: {}, + addEventListener: (ev, fn) => { + eventListeners[ev] = fn; + }, + appendChild: sandbox.stub(), + setAttribute: sandbox.stub(), + textContent: "", + }; + element.classList.add = sandbox.stub(); + element.classList.includes = className => + element.classList.add.firstCall.args[0] === className; + createdCustomElements.push(element); + return element; + }, + l10n: { + translateElements: sandbox.stub(), + translateFragment: sandbox.stub(), + formatMessages: sandbox.stub().resolves([{}]), + setAttributes: sandbox.stub(), + }, + }; + fakeWindow = { + // eslint-disable-next-line object-shorthand + DocumentFragment: function () { + return fakeElementById; + }, + document: fakeDocument, + browser: { + ownerDocument: fakeDocument, + }, + MozXULElement: { insertFTLIfNeeded: sandbox.stub() }, + ownerGlobal: { + openLinkIn: sandbox.stub(), + gBrowser: "gBrowser", + }, + PanelUI: { + panel: fakeElementById, + whatsNewPanel: fakeElementById, + }, + customElements: { get: sandbox.stub() }, + }; + everyWindowStub = { + registerCallback: sandbox.stub(), + unregisterCallback: sandbox.stub(), + }; + preferencesStub = { + get: sandbox.stub(), + set: sandbox.stub(), + }; + scriptloaderStub = { loadSubScript: sandbox.stub() }; + addObserverStub = sandbox.stub(); + removeObserverStub = sandbox.stub(); + getBoolPrefStub = sandbox.stub(); + setBoolPrefStub = sandbox.stub(); + fakeSendTelemetry = sandbox.stub(); + isBrowserPrivateStub = sandbox.stub(); + getEarliestRecordedDateStub = sandbox.stub().returns( + // A random date that's not the current timestamp + new Date() - 500 + ); + getEventsByDateRangeStub = sandbox.stub().returns([]); + getViewNodeStub = sandbox.stub().returns(fakeElementById); + defaultSearchStub = { defaultEngine: { name: "DDG" } }; + fakeRemoteL10n = { + l10n: {}, + reloadL10n: sandbox.stub(), + createElement: sandbox + .stub() + .callsFake((doc, el) => fakeDocument.createElement(el)), + }; + globals.set({ + EveryWindow: everyWindowStub, + Services: { + ...Services, + prefs: { + addObserver: addObserverStub, + removeObserver: removeObserverStub, + getBoolPref: getBoolPrefStub, + setBoolPref: setBoolPrefStub, + }, + search: defaultSearchStub, + scriptloader: scriptloaderStub, + }, + PrivateBrowsingUtils: { + isBrowserPrivate: isBrowserPrivateStub, + }, + Preferences: preferencesStub, + TrackingDBService: { + getEarliestRecordedDate: getEarliestRecordedDateStub, + getEventsByDateRange: getEventsByDateRangeStub, + }, + SpecialMessageActions: { + handleAction: sandbox.stub(), + }, + RemoteL10n: fakeRemoteL10n, + PanelMultiView: { + getViewNode: getViewNodeStub, + }, + }); + }); + afterEach(() => { + instance.uninit(); + sandbox.restore(); + globals.restore(); + eventListeners = {}; + createdCustomElements = []; + }); + it("should create an instance", () => { + assert.ok(instance); + }); + it("should enableAppmenuButton() on init() just once", async () => { + instance.enableAppmenuButton = sandbox.stub(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledOnce(instance.enableAppmenuButton); + + instance.uninit(); + + await instance.init(waitForInitializedStub, { getMessages: () => {} }); + + assert.calledTwice(instance.enableAppmenuButton); + }); + it("should unregisterCallback on uninit()", () => { + instance.uninit(); + assert.calledTwice(everyWindowStub.unregisterCallback); + }); + describe("#maybeLoadCustomElement", () => { + it("should not load customElements a second time", () => { + instance.maybeLoadCustomElement({ customElements: new Map() }); + instance.maybeLoadCustomElement({ + customElements: new Map([["remote-text", true]]), + }); + + assert.calledOnce(scriptloaderStub.loadSubScript); + }); + }); + describe("#toggleWhatsNewPref", () => { + it("should call Preferences.set() with the opposite value", () => { + let checkbox = {}; + let event = { target: checkbox }; + // checkbox starts false + checkbox.checked = false; + + // toggling the checkbox to set the value to true; + // Preferences.set() gets called before the checkbox changes, + // so we have to call it with the opposite value. + instance.toggleWhatsNewPref(event); + + assert.calledOnce(preferencesStub.set); + assert.calledWith( + preferencesStub.set, + "browser.messaging-system.whatsNewPanel.enabled", + !checkbox.checked + ); + }); + it("should report telemetry with the opposite value", () => { + let sendUserEventTelemetryStub = sandbox.stub( + instance, + "sendUserEventTelemetry" + ); + let event = { + target: { checked: true, ownerGlobal: fakeWindow }, + }; + + instance.toggleWhatsNewPref(event); + + assert.calledOnce(sendUserEventTelemetryStub); + const { args } = sendUserEventTelemetryStub.firstCall; + assert.equal(args[1], "WNP_PREF_TOGGLE"); + assert.propertyVal(args[3].value, "prefValue", false); + }); + }); + describe("#enableAppmenuButton", () => { + it("should registerCallback on enableAppmenuButton() if there are messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "appMenu-whatsnew-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableAppmenuButton() if there are no messages", async () => { + instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableAppmenuButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("#disableAppmenuButton", () => { + it("should call the unregisterCallback", () => { + assert.notCalled(everyWindowStub.unregisterCallback); + + instance.disableAppmenuButton(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "appMenu-whatsnew-button" + ); + }); + }); + describe("#enableToolbarButton", () => { + it("should registerCallback on enableToolbarButton if messages.length", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([{}, {}]), + }); + // init calls `enableAppmenuButton` + everyWindowStub.registerCallback.resetHistory(); + + await instance.enableToolbarButton(); + + assert.calledOnce(everyWindowStub.registerCallback); + assert.calledWithExactly( + everyWindowStub.registerCallback, + "whats-new-menu-button", + sinon.match.func, + sinon.match.func + ); + }); + it("should not registerCallback on enableToolbarButton if no messages", async () => { + await instance.init(waitForInitializedStub, { + getMessages: sandbox.stub().resolves([]), + }); + + await instance.enableToolbarButton(); + + assert.notCalled(everyWindowStub.registerCallback); + }); + }); + describe("Show/Hide functions", () => { + it("should unhide appmenu button on _showAppmenuButton()", async () => { + await instance._showAppmenuButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide appmenu button on _hideAppmenuButton()", () => { + instance._hideAppmenuButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + it("should not do anything if the window is closed", () => { + instance._hideAppmenuButton(fakeWindow, true); + assert.notCalled(global.PanelMultiView.getViewNode); + }); + it("should not throw if the element does not exist", () => { + let fn = instance._hideAppmenuButton.bind(null, { + browser: { ownerDocument: {} }, + }); + getViewNodeStub.returns(undefined); + assert.doesNotThrow(fn); + }); + it("should unhide toolbar button on _showToolbarButton()", async () => { + await instance._showToolbarButton(fakeWindow); + + assert.equal(fakeElementById.hidden, false); + }); + it("should hide toolbar button on _hideToolbarButton()", () => { + instance._hideToolbarButton(fakeWindow); + assert.equal(fakeElementById.hidden, true); + }); + }); + describe("#renderMessages", () => { + let getMessagesStub; + beforeEach(() => { + getMessagesStub = sandbox.stub(); + instance.init(waitForInitializedStub, { + getMessages: getMessagesStub, + sendTelemetry: fakeSendTelemetry, + }); + }); + it("should have correct state", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.propertyVal(instance.state.contentArguments, "trackerCount", 4); + assert.propertyVal( + instance.state.contentArguments, + "fingerprinterCount", + 3 + ); + }); + it("should render messages to the panel on renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + messages[0].content.link_text = { string_id: "link_text_id" }; + + getMessagesStub.returns(messages); + const ev1 = sandbox.stub(); + ev1.withArgs("type").returns(1); // tracker + ev1.withArgs("count").returns(4); + const ev2 = sandbox.stub(); + ev2.withArgs("type").returns(4); // fingerprinter + ev2.withArgs("count").returns(3); + getEventsByDateRangeStub.returns([ + { getResultByName: ev1 }, + { getResultByName: ev2 }, + ]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + for (let message of messages) { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + ); + if (message.content.layout === "tracking-protections") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-subtitle" + ) + ); + } + if (message.id === "WHATS_NEW_FINGERPRINTER_COUNTER_72") { + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => el === "h2" && args.content === 3 + ) + ); + } + assert.ok( + fakeRemoteL10n.createElement.args.find( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-content" + ) + ); + } + // Call the click handler to make coverage happy. + eventListeners.mouseup(); + assert.calledOnce(global.SpecialMessageActions.handleAction); + }); + it("should clear previous messages on 2nd renderMessages()", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const removeStub = sandbox.stub(); + fakeElementById.querySelectorAll.onCall(0).returns([]); + fakeElementById.querySelectorAll + .onCall(1) + .returns([{ remove: removeStub }, { remove: removeStub }]); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledTwice(removeStub); + }); + it("should sort based on order field value", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => + m.template === "whatsnew_panel_message" && + m.content.published_date === 1560969794394 + ); + + messages.forEach(m => (m.content.title = m.order)); + + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + // Select the title elements that are supposed to be set to the same + // value as the `order` field of the message + const titleEls = fakeRemoteL10n.createElement.args + .filter( + ([doc, el, args]) => + args && args.classList === "whatsNew-message-title" + ) + .map(([doc, el, args]) => args.content); + assert.deepEqual(titleEls, [1, 2, 3]); + }); + it("should accept string for image attributes", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.id === "WHATS_NEW_70_1" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const imageEl = createdCustomElements.find(el => el.tagName === "img"); + assert.calledOnce(imageEl.setAttribute); + assert.calledWithExactly( + imageEl.setAttribute, + "alt", + "Firefox Send Logo" + ); + }); + it("should set state values as data-attribute", async () => { + const message = (await PanelTestProvider.getMessages()).find( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([message]); + instance.state.contentArguments = { foo: "foo", bar: "bar" }; + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const [, , args] = fakeRemoteL10n.createElement.args.find( + ([doc, el, elArgs]) => elArgs && elArgs.attributes + ); + assert.ok(args); + // Currently this.state.contentArguments has 8 different entries + assert.lengthOf(Object.keys(args.attributes), 8); + assert.equal( + args.attributes.searchEngineName, + defaultSearchStub.defaultEngine.name + ); + }); + it("should only render unique dates (no duplicates)", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + const uniqueDates = [ + ...new Set(messages.map(m => m.content.published_date)), + ]; + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const dateElements = fakeRemoteL10n.createElement.args.filter( + ([doc, el, args]) => + el === "p" && args.classList === "whatsNew-message-date" + ); + assert.lengthOf(dateElements, uniqueDates.length); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + fakeDocument.getElementById + .withArgs("customizationui-widget-panel") + .returns(null); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.notCalled(fakeElementById.addEventListener); + }); + it("should attach doCommand cbs that handle user actions", async () => { + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + const messageEl = createdCustomElements.find( + el => + el.tagName === "div" && el.classList.includes("whatsNew-message-body") + ); + const anchorEl = createdCustomElements.find(el => el.tagName === "a"); + + assert.notCalled(global.SpecialMessageActions.handleAction); + + messageEl.doCommand(); + anchorEl.doCommand(); + + assert.calledTwice(global.SpecialMessageActions.handleAction); + }); + it("should listen for panelhidden and remove the toolbar button", async () => { + getMessagesStub.returns([]); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(fakeElementById.addEventListener); + assert.calledWithExactly( + fakeElementById.addEventListener, + "popuphidden", + sinon.match.func, + { + once: true, + } + ); + const [, cb] = fakeElementById.addEventListener.firstCall.args; + + assert.notCalled(everyWindowStub.unregisterCallback); + + cb(); + + assert.calledOnce(everyWindowStub.unregisterCallback); + assert.calledWithExactly( + everyWindowStub.unregisterCallback, + "whats-new-menu-button" + ); + }); + describe("#IMPRESSION", () => { + it("should dispatch a IMPRESSION for messages", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + assert.propertyVal( + spy.firstCall.args[2], + "id", + messages + .map(({ id }) => id) + .sort() + .join(",") + ); + }); + it("should dispatch a CLICK for clicking a message", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + // Force to render the message + fakeElementById.querySelector.returns(null); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.returns([messages[0]]); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledOnce(fakeSendTelemetry); + + spy.resetHistory(); + + // Message click event listener cb + eventListeners.mouseup(); + + assert.calledOnce(spy); + assert.calledWithExactly(spy, fakeWindow, "CLICK", messages[0]); + }); + it("should dispatch a IMPRESSION with toolbar_dropdown", async () => { + // means panel is triggered from the toolbar button + fakeElementById.hasAttribute.returns(true); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "toolbar_dropdown", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "toolbar_dropdown", + }); + }); + it("should dispatch a IMPRESSION with application_menu", async () => { + // means panel is triggered as a subview in the application menu + fakeElementById.hasAttribute.returns(false); + const messages = (await PanelTestProvider.getMessages()).filter( + m => m.template === "whatsnew_panel_message" + ); + getMessagesStub.resolves(messages); + const spy = sandbox.spy(instance, "sendUserEventTelemetry"); + const panelPingId = messages + .map(({ id }) => id) + .sort() + .join(","); + + await instance.renderMessages(fakeWindow, fakeDocument, "container-id"); + + assert.calledOnce(spy); + assert.calledWithExactly( + spy, + fakeWindow, + "IMPRESSION", + { + id: panelPingId, + }, + { + value: { + view: "application_menu", + }, + } + ); + assert.calledOnce(fakeSendTelemetry); + const { + args: [dispatchPayload], + } = fakeSendTelemetry.lastCall; + assert.propertyVal(dispatchPayload, "type", "TOOLBAR_PANEL_TELEMETRY"); + assert.propertyVal(dispatchPayload.data, "message_id", panelPingId); + assert.deepEqual(dispatchPayload.data.event_context, { + view: "application_menu", + }); + }); + }); + describe("#forceShowMessage", () => { + const panelSelector = "PanelUI-whatsNew-message-container"; + let removeMessagesSpy; + let renderMessagesStub; + let addEventListenerStub; + let messages; + let browser; + beforeEach(async () => { + messages = (await PanelTestProvider.getMessages()).find( + m => m.id === "WHATS_NEW_70_1" + ); + removeMessagesSpy = sandbox.spy(instance, "removeMessages"); + renderMessagesStub = sandbox.spy(instance, "renderMessages"); + addEventListenerStub = fakeElementById.addEventListener; + browser = { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }; + fakeElementById.querySelectorAll.returns([fakeElementById]); + }); + it("should call removeMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should call renderMessages when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(renderMessagesStub); + assert.calledWithExactly( + renderMessagesStub, + fakeWindow, + fakeDocument, + panelSelector, + { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + } + ); + }); + it("should cleanup after the panel is hidden when forcing a message to show", () => { + instance.forceShowMessage(browser, messages); + + assert.calledOnce(addEventListenerStub); + assert.calledWithExactly( + addEventListenerStub, + "popuphidden", + sinon.match.func + ); + + const [, cb] = addEventListenerStub.firstCall.args; + // Reset the call count from the first `forceShowMessage` call + removeMessagesSpy.resetHistory(); + cb({ target: { ownerGlobal: fakeWindow } }); + + assert.calledOnce(removeMessagesSpy); + assert.calledWithExactly(removeMessagesSpy, fakeWindow, panelSelector); + }); + it("should exit gracefully if called before a browser exists", () => { + instance.forceShowMessage(null, messages); + assert.neverCalledWith(removeMessagesSpy, fakeWindow, panelSelector); + }); + }); + }); + describe("#insertProtectionPanelMessage", () => { + const fakeInsert = () => + instance.insertProtectionPanelMessage({ + target: { ownerGlobal: fakeWindow, ownerDocument: fakeDocument }, + }); + let getMessagesStub; + beforeEach(async () => { + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + getMessagesStub = sandbox + .stub() + .resolves( + onboardingMsgs.find(msg => msg.template === "protections_panel") + ); + await instance.init(waitForInitializedStub, { + sendTelemetry: fakeSendTelemetry, + getMessages: getMessagesStub, + }); + }); + it("should remember it showed", async () => { + await fakeInsert(); + + assert.calledWithExactly( + setBoolPrefStub, + "browser.protections_panel.infoMessage.seen", + true + ); + }); + it("should toggle/expand when default collapsed/disabled", async () => { + fakeElementById.hasAttribute.returns(true); + + await fakeInsert(); + + assert.calledThrice(fakeElementById.toggleAttribute); + }); + it("should toggle again when popup hides", async () => { + fakeElementById.addEventListener.callsArg(1); + + await fakeInsert(); + + assert.callCount(fakeElementById.toggleAttribute, 6); + }); + it("should open link on click (separate link element)", async () => { + const sendTelemetryStub = sandbox.stub( + instance, + "sendUserEventTelemetry" + ); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + const msg = onboardingMsgs.find(m => m.template === "protections_panel"); + + await fakeInsert(); + + assert.calledOnce(sendTelemetryStub); + assert.calledWithExactly( + sendTelemetryStub, + fakeWindow, + "IMPRESSION", + msg + ); + + eventListeners.mouseup(); + + assert.calledOnce(global.SpecialMessageActions.handleAction); + assert.calledWithExactly( + global.SpecialMessageActions.handleAction, + { + type: "OPEN_URL", + data: { + args: sinon.match.string, + where: "tabshifted", + }, + }, + fakeWindow.browser + ); + }); + it("should format the url", async () => { + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .returns("formattedURL"); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + const msg = onboardingMsgs.find(m => m.template === "protections_panel"); + + await fakeInsert(); + + eventListeners.mouseup(); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, msg.content.cta_url); + assert.calledOnce(global.SpecialMessageActions.handleAction); + assert.calledWithExactly( + global.SpecialMessageActions.handleAction, + { + type: "OPEN_URL", + data: { + args: "formattedURL", + where: "tabshifted", + }, + }, + fakeWindow.browser + ); + }); + it("should report format url errors", async () => { + const stub = sandbox + .stub(global.Services.urlFormatter, "formatURL") + .throws(); + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + const msg = onboardingMsgs.find(m => m.template === "protections_panel"); + sandbox.spy(global.console, "error"); + + await fakeInsert(); + + eventListeners.mouseup(); + + assert.calledOnce(stub); + assert.calledOnce(global.console.error); + assert.calledOnce(global.SpecialMessageActions.handleAction); + assert.calledWithExactly( + global.SpecialMessageActions.handleAction, + { + type: "OPEN_URL", + data: { + args: msg.content.cta_url, + where: "tabshifted", + }, + }, + fakeWindow.browser + ); + }); + it("should open link on click (directly attached to the message)", async () => { + const onboardingMsgs = + await OnboardingMessageProvider.getUntranslatedMessages(); + const msg = onboardingMsgs.find(m => m.template === "protections_panel"); + getMessagesStub.resolves({ + ...msg, + content: { ...msg.content, link_text: null }, + }); + await fakeInsert(); + + eventListeners.mouseup(); + + assert.calledOnce(global.SpecialMessageActions.handleAction); + assert.calledWithExactly( + global.SpecialMessageActions.handleAction, + { + type: "OPEN_URL", + data: { + args: sinon.match.string, + where: "tabshifted", + }, + }, + fakeWindow.browser + ); + }); + it("should handle user actions from mouseup and keyup", async () => { + await fakeInsert(); + + eventListeners.mouseup(); + eventListeners.keyup({ key: "Enter" }); + eventListeners.keyup({ key: " " }); + assert.calledThrice(global.SpecialMessageActions.handleAction); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js new file mode 100644 index 0000000000..a173c16cde --- /dev/null +++ b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js @@ -0,0 +1,3020 @@ +"use strict"; + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils"; +import { + insertPinned, + TOP_SITES_DEFAULT_ROWS, + TOP_SITES_MAX_SITES_PER_ROW, +} from "common/Reducers.sys.mjs"; +import { getDefaultOptions } from "lib/ActivityStreamStorage.jsm"; +import injector from "inject!lib/TopSitesFeed.jsm"; +import { Screenshots } from "lib/Screenshots.jsm"; +import { LinksCache } from "lib/LinksCache.sys.mjs"; + +const FAKE_FAVICON = "data987"; +const FAKE_FAVICON_SIZE = 128; +const FAKE_FRECENCY = 200; +const FAKE_LINKS = new Array(2 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); +const FAKE_SCREENSHOT = "data123"; +const SEARCH_SHORTCUTS_EXPERIMENT_PREF = "improvesearch.topSiteSearchShortcuts"; +const SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF = + "improvesearch.topSiteSearchShortcuts.searchEngines"; +const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = + "improvesearch.topSiteSearchShortcuts.havePinned"; +const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; +const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; +const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting"; +const CONTILE_CACHE_PREF = "browser.topsites.contile.cachedTiles"; +const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor"; +const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch"; + +function FakeTippyTopProvider() {} +FakeTippyTopProvider.prototype = { + async init() { + this.initialized = true; + }, + processSite(site) { + return site; + }, +}; + +describe("Top Sites Feed", () => { + let TopSitesFeed; + let DEFAULT_TOP_SITES; + let feed; + let globals; + let sandbox; + let links; + let fakeNewTabUtils; + let fakeScreenshot; + let filterAdultStub; + let shortURLStub; + let fakePageThumbs; + let fetchStub; + let fakeNimbusFeatures; + let fakeSampling; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + fakeNewTabUtils = { + blockedLinks: { + links: [], + isBlocked: () => false, + unblock: sandbox.spy(), + }, + activityStreamLinks: { + getTopSites: sandbox.spy(() => Promise.resolve(links)), + }, + activityStreamProvider: { + _addFavicons: sandbox.spy(l => + Promise.resolve( + l.map(link => { + link.favicon = FAKE_FAVICON; + link.faviconSize = FAKE_FAVICON_SIZE; + return link; + }) + ) + ), + _faviconBytesToDataURI: sandbox.spy(), + }, + pinnedLinks: { + links: [], + isPinned: () => false, + pin: sandbox.spy(), + unpin: sandbox.spy(), + }, + }; + fakeScreenshot = { + getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)), + maybeCacheScreenshot: sandbox.spy(Screenshots.maybeCacheScreenshot), + _shouldGetScreenshots: sinon.stub().returns(true), + }; + filterAdultStub = { + filter: sinon.stub().returnsArg(0), + }; + shortURLStub = sinon + .stub() + .callsFake(site => + site.url.replace(/(.com|.ca)/, "").replace("https://", "") + ); + const fakeDedupe = function () {}; + fakePageThumbs = { + addExpirationFilter: sinon.stub(), + removeExpirationFilter: sinon.stub(), + }; + fakeNimbusFeatures = { + newtab: { + getVariable: sinon.stub(), + onUpdate: sinon.stub(), + offUpdate: sinon.stub(), + }, + pocketNewtab: { + getVariable: sinon.stub(), + }, + }; + fakeSampling = { + ratioSample: sinon.stub(), + }; + globals.set({ + PageThumbs: fakePageThumbs, + NewTabUtils: fakeNewTabUtils, + gFilterAdultEnabled: false, + NimbusFeatures: fakeNimbusFeatures, + LinksCache, + FilterAdult: filterAdultStub, + Screenshots: fakeScreenshot, + Sampling: fakeSampling, + }); + sandbox.spy(global.XPCOMUtils, "defineLazyGetter"); + FAKE_GLOBAL_PREFS.set("default.sites", "https://foo.com/"); + ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({ + "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, + "common/Dedupe.jsm": { Dedupe: fakeDedupe }, + "common/Reducers.jsm": { + insertPinned, + TOP_SITES_DEFAULT_ROWS, + TOP_SITES_MAX_SITES_PER_ROW, + }, + "lib/FilterAdult.jsm": { FilterAdult: filterAdultStub }, + "lib/Screenshots.jsm": { Screenshots: fakeScreenshot }, + "lib/TippyTopProvider.sys.mjs": { + TippyTopProvider: FakeTippyTopProvider, + }, + "lib/ShortURL.jsm": { shortURL: shortURLStub }, + "lib/ActivityStreamStorage.jsm": { + ActivityStreamStorage: function Fake() {}, + getDefaultOptions, + }, + })); + feed = new TopSitesFeed(); + const storage = { + init: sandbox.stub().resolves(), + get: sandbox.stub().resolves(), + set: sandbox.stub().resolves(), + }; + // Setup for tests that don't call `init` but require feed.storage + feed._storage = storage; + feed.store = { + dispatch: sinon.spy(), + getState() { + return this.state; + }, + state: { + Prefs: { values: { topSitesRows: 2 } }, + TopSites: { rows: Array(12).fill("site") }, + }, + dbStorage: { getDbTable: sandbox.stub().returns(storage) }, + }; + feed.dedupe.group = (...sites) => sites; + links = FAKE_LINKS; + // Turn off the search shortcuts experiment by default for other tests + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = + "google,amazon"; + }); + afterEach(() => { + globals.restore(); + sandbox.restore(); + }); + + function stubFaviconsToUseScreenshots() { + fakeNewTabUtils.activityStreamProvider._addFavicons = sandbox.stub(); + } + + describe("#constructor", () => { + it("should defineLazyGetter for log, contextId, and _currentSearchHostname", () => { + assert.calledThrice(global.XPCOMUtils.defineLazyGetter); + + let spyCall = global.XPCOMUtils.defineLazyGetter.getCall(0); + assert.ok(spyCall.calledWith(sinon.match.any, "log", sinon.match.func)); + + spyCall = global.XPCOMUtils.defineLazyGetter.getCall(1); + assert.ok( + spyCall.calledWith(sinon.match.any, "contextId", sinon.match.func) + ); + + spyCall = global.XPCOMUtils.defineLazyGetter.getCall(2); + assert.ok( + spyCall.calledWith(feed, "_currentSearchHostname", sinon.match.func) + ); + }); + }); + + describe("#refreshDefaults", () => { + it("should add defaults on PREFS_INITIAL_VALUES", () => { + feed.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "https://foo.com" }, + }); + + assert.isAbove(DEFAULT_TOP_SITES.length, 0); + }); + it("should add defaults on default.sites PREF_CHANGED", () => { + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: "default.sites", value: "https://foo.com" }, + }); + + assert.isAbove(DEFAULT_TOP_SITES.length, 0); + }); + it("should refresh on topSiteRows PREF_CHANGED", () => { + feed.refresh = sinon.spy(); + feed.onAction({ type: at.PREF_CHANGED, data: { name: "topSitesRows" } }); + + assert.calledOnce(feed.refresh); + }); + it("should have default sites with .isDefault = true", () => { + feed.refreshDefaults("https://foo.com"); + + DEFAULT_TOP_SITES.forEach(link => + assert.propertyVal(link, "isDefault", true) + ); + }); + it("should have default sites with appropriate hostname", () => { + feed.refreshDefaults("https://foo.com"); + + DEFAULT_TOP_SITES.forEach(link => + assert.propertyVal(link, "hostname", shortURLStub(link)) + ); + }); + it("should add no defaults on empty pref", () => { + feed.refreshDefaults(""); + + assert.equal(DEFAULT_TOP_SITES.length, 0); + }); + it("should clear defaults", () => { + feed.refreshDefaults("https://foo.com"); + feed.refreshDefaults(""); + + assert.equal(DEFAULT_TOP_SITES.length, 0); + }); + }); + describe("#filterForThumbnailExpiration", () => { + it("should pass rows.urls to the callback provided", () => { + const rows = [ + { url: "foo.com" }, + { url: "bar.com", customScreenshotURL: "custom" }, + ]; + feed.store.state.TopSites = { rows }; + const stub = sinon.stub(); + + feed.filterForThumbnailExpiration(stub); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, ["foo.com", "bar.com", "custom"]); + }); + }); + describe("#getLinksWithDefaults", () => { + beforeEach(() => { + feed.refreshDefaults("https://foo.com"); + }); + + describe("general", () => { + it("should get the links from NewTabUtils", async () => { + const result = await feed.getLinksWithDefaults(); + const reference = links.map(site => + Object.assign({}, site, { + hostname: shortURLStub(site), + typedBonus: true, + }) + ); + + assert.deepEqual(result, reference); + assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); + }); + it("should indicate the links get typed bonus", async () => { + const result = await feed.getLinksWithDefaults(); + + assert.propertyVal(result[0], "typedBonus", true); + }); + it("should filter out non-pinned adult sites", async () => { + filterAdultStub.filter = sinon.stub().returns([]); + fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; + + const result = await feed.getLinksWithDefaults(); + + // The stub filters out everything + assert.calledOnce(filterAdultStub.filter); + assert.equal(result.length, 1); + assert.equal(result[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); + }); + it("should filter out the defaults that have been blocked", async () => { + // make sure we only have one top site, and we block the only default site we have to show + const url = "www.myonlytopsite.com"; + const topsite = { + frecency: FAKE_FRECENCY, + hostname: shortURLStub({ url }), + typedBonus: true, + url, + }; + const blockedDefaultSite = { url: "https://foo.com" }; + fakeNewTabUtils.activityStreamLinks.getTopSites = () => [topsite]; + fakeNewTabUtils.blockedLinks.isBlocked = site => + site.url === blockedDefaultSite.url; + const result = await feed.getLinksWithDefaults(); + + // what we should be left with is just the top site we added, and not the default site we blocked + assert.lengthOf(result, 1); + assert.deepEqual(result[0], topsite); + assert.notInclude(result, blockedDefaultSite); + }); + it("should call dedupe on the links", async () => { + const stub = sinon.stub(feed.dedupe, "group").callsFake((...id) => id); + + await feed.getLinksWithDefaults(); + + assert.calledOnce(stub); + }); + it("should dedupe the links by hostname", async () => { + const site = { url: "foo", hostname: "bar" }; + const result = feed._dedupeKey(site); + + assert.equal(result, site.hostname); + }); + it("should add defaults if there are are not enough links", async () => { + links = [{ frecency: FAKE_FRECENCY, url: "foo.com" }]; + + const result = await feed.getLinksWithDefaults(); + const reference = [...links, ...DEFAULT_TOP_SITES].map(s => + Object.assign({}, s, { + hostname: shortURLStub(s), + typedBonus: true, + }) + ); + + assert.deepEqual(result, reference); + }); + it("should only add defaults up to the number of visible slots", async () => { + links = []; + const numVisible = TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; + for (let i = 0; i < numVisible - 1; i++) { + links.push({ frecency: FAKE_FRECENCY, url: `foo${i}.com` }); + } + const result = await feed.getLinksWithDefaults(); + const reference = [...links, DEFAULT_TOP_SITES[0]].map(s => + Object.assign({}, s, { + hostname: shortURLStub(s), + typedBonus: true, + }) + ); + + assert.lengthOf(result, numVisible); + assert.deepEqual(result, reference); + }); + it("should not throw if NewTabUtils returns null", () => { + links = null; + assert.doesNotThrow(() => { + feed.getLinksWithDefaults(); + }); + }); + it("should get more if the user has asked for more", async () => { + links = new Array(4 * TOP_SITES_MAX_SITES_PER_ROW) + .fill(null) + .map((v, i) => ({ + frecency: FAKE_FRECENCY, + url: `http://www.site${i}.com`, + })); + feed.store.state.Prefs.values.topSitesRows = 3; + + const result = await feed.getLinksWithDefaults(); + + assert.propertyVal( + result, + "length", + feed.store.state.Prefs.values.topSitesRows * + TOP_SITES_MAX_SITES_PER_ROW + ); + }); + }); + describe("caching", () => { + it("should reuse the cache on subsequent calls", async () => { + await feed.getLinksWithDefaults(); + await feed.getLinksWithDefaults(); + + assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); + }); + it("should ignore the cache when requesting more", async () => { + await feed.getLinksWithDefaults(); + feed.store.state.Prefs.values.topSitesRows *= 3; + + await feed.getLinksWithDefaults(); + + assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites); + }); + it("should migrate frecent screenshot data without getting screenshots again", async () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + stubFaviconsToUseScreenshots(); + await feed.getLinksWithDefaults(); + const { callCount } = fakeScreenshot.getScreenshotForURL; + feed.frecentCache.expire(); + + const result = await feed.getLinksWithDefaults(); + + assert.calledTwice(global.NewTabUtils.activityStreamLinks.getTopSites); + assert.callCount(fakeScreenshot.getScreenshotForURL, callCount); + assert.propertyVal(result[0], "screenshot", FAKE_SCREENSHOT); + }); + it("should migrate pinned favicon data without getting favicons again", async () => { + fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; + await feed.getLinksWithDefaults(); + const { callCount } = + fakeNewTabUtils.activityStreamProvider._addFavicons; + feed.pinnedCache.expire(); + + const result = await feed.getLinksWithDefaults(); + + assert.callCount( + fakeNewTabUtils.activityStreamProvider._addFavicons, + callCount + ); + assert.propertyVal(result[0], "favicon", FAKE_FAVICON); + assert.propertyVal(result[0], "faviconSize", FAKE_FAVICON_SIZE); + }); + it("should not expose internal link properties", async () => { + const result = await feed.getLinksWithDefaults(); + + const internal = Object.keys(result[0]).filter(key => + key.startsWith("__") + ); + assert.equal(internal.join(""), ""); + }); + it("should copy the screenshot of the frecent site if pinned site doesn't have customScreenshotURL", async () => { + links = [{ url: "https://foo.com/", screenshot: "screenshot" }]; + fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; + + const result = await feed.getLinksWithDefaults(); + + assert.equal(result[0].screenshot, links[0].screenshot); + }); + it("should not copy the frecent screenshot if customScreenshotURL is set", async () => { + links = [{ url: "https://foo.com/", screenshot: "screenshot" }]; + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://foo.com/", customScreenshotURL: "custom" }, + ]; + + const result = await feed.getLinksWithDefaults(); + + assert.isUndefined(result[0].screenshot); + }); + it("should keep the same screenshot if no frecent site is found", async () => { + links = []; + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://foo.com/", screenshot: "custom" }, + ]; + + const result = await feed.getLinksWithDefaults(); + + assert.equal(result[0].screenshot, "custom"); + }); + it("should not overwrite pinned site screenshot", async () => { + links = [{ url: "https://foo.com/", screenshot: "foo" }]; + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://foo.com/", screenshot: "bar" }, + ]; + + const result = await feed.getLinksWithDefaults(); + + assert.equal(result[0].screenshot, "bar"); + }); + it("should not set searchTopSite from frecent site", async () => { + links = [ + { + url: "https://foo.com/", + searchTopSite: true, + screenshot: "screenshot", + }, + ]; + fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; + + const result = await feed.getLinksWithDefaults(); + + assert.propertyVal(result[0], "searchTopSite", false); + // But it should copy over other properties + assert.propertyVal(result[0], "screenshot", "screenshot"); + }); + describe("concurrency", () => { + beforeEach(() => { + stubFaviconsToUseScreenshots(); + fakeScreenshot.getScreenshotForURL = sandbox + .stub() + .resolves(FAKE_SCREENSHOT); + }); + afterEach(() => { + sandbox.restore(); + }); + + const getTwice = () => + Promise.all([ + feed.getLinksWithDefaults(), + feed.getLinksWithDefaults(), + ]); + + it("should call the backing data once", async () => { + await getTwice(); + + assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites); + }); + it("should get screenshots once per link", async () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + await getTwice(); + + assert.callCount( + fakeScreenshot.getScreenshotForURL, + FAKE_LINKS.length + ); + }); + it("should dispatch once per link screenshot fetched", async () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + feed._requestRichIcon = sinon.stub(); + await getTwice(); + + assert.callCount(feed.store.dispatch, FAKE_LINKS.length); + }); + }); + }); + describe("deduping", () => { + beforeEach(() => { + ({ TopSitesFeed, DEFAULT_TOP_SITES } = injector({ + "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, + "common/Reducers.jsm": { + insertPinned, + TOP_SITES_DEFAULT_ROWS, + TOP_SITES_MAX_SITES_PER_ROW, + }, + "lib/Screenshots.jsm": { Screenshots: fakeScreenshot }, + })); + sandbox.stub(global.Services.eTLD, "getPublicSuffix").returns("com"); + feed = Object.assign(new TopSitesFeed(), { store: feed.store }); + }); + it("should not dedupe pinned sites", async () => { + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]; + + const sites = await feed.getLinksWithDefaults(); + + assert.lengthOf(sites, 2 * TOP_SITES_MAX_SITES_PER_ROW); + assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); + assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url); + assert.equal(sites[0].hostname, sites[1].hostname); + }); + it("should prefer pinned sites over links", async () => { + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://developer.mozilla.org/en-US/docs/Web" }, + { url: "https://developer.mozilla.org/en-US/docs/Learn" }, + ]; + // These will be the frecent results. + links = [ + { frecency: FAKE_FRECENCY, url: "https://developer.mozilla.org/" }, + { frecency: FAKE_FRECENCY, url: "https://www.mozilla.org/" }, + ]; + + const sites = await feed.getLinksWithDefaults(); + + // Expecting 3 links where there's 2 pinned and 1 www.mozilla.org, so + // the frecent with matching hostname as pinned is removed. + assert.lengthOf(sites, 3); + assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url); + assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url); + assert.equal(sites[2].url, links[1].url); + }); + it("should return sites that have a title", async () => { + // Simulate a pinned link with no title. + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://github.com/mozilla/activity-stream" }, + ]; + + const sites = await feed.getLinksWithDefaults(); + + for (const site of sites) { + assert.isDefined(site.hostname); + } + }); + it("should check against null entries", async () => { + fakeNewTabUtils.pinnedLinks.links = [null]; + + await feed.getLinksWithDefaults(); + }); + }); + it("should call _fetchIcon for each link", async () => { + sinon.spy(feed, "_fetchIcon"); + + const results = await feed.getLinksWithDefaults(); + + assert.callCount(feed._fetchIcon, results.length); + results.forEach(link => { + assert.calledWith(feed._fetchIcon, link); + }); + }); + it("should call _fetchScreenshot when customScreenshotURL is set", async () => { + links = []; + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://foo.com", customScreenshotURL: "custom" }, + ]; + sinon.stub(feed, "_fetchScreenshot"); + + await feed.getLinksWithDefaults(); + + assert.calledWith(feed._fetchScreenshot, sinon.match.object, "custom"); + }); + describe("discoverystream", () => { + let makeStreamData = index => ({ + layout: [ + { + components: [ + { + placement: { + name: "sponsored-topsites", + }, + spocs: { + positions: [{ index }], + }, + }, + ], + }, + ], + spocs: { + data: { + "sponsored-topsites": { + items: [{ title: "test spoc", url: "https://test-spoc.com" }], + }, + }, + }, + }); + it("should add a sponsored topsite from discoverystream to all the valid indices", async () => { + for (let i = 0; i < FAKE_LINKS.length; i++) { + feed.store.state.DiscoveryStream = makeStreamData(i); + const result = await feed.getLinksWithDefaults(); + const link = result[i]; + + assert.equal(link.type, "SPOC"); + assert.equal(link.title, "test spoc"); + assert.equal(link.sponsored_position, i + 1); + assert.equal(link.hostname, "test-spoc"); + assert.equal(link.url, "https://test-spoc.com"); + } + }); + }); + }); + describe("#init", () => { + it("should call refresh (broadcast:true)", async () => { + sandbox.stub(feed, "refresh"); + + await feed.init(); + + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { + broadcast: true, + isStartup: true, + }); + }); + it("should initialise the storage", async () => { + await feed.init(); + + assert.calledOnce(feed.store.dbStorage.getDbTable); + assert.calledWithExactly(feed.store.dbStorage.getDbTable, "sectionPrefs"); + }); + it("should call onUpdate to set up Nimbus update listener", async () => { + await feed.init(); + + assert.calledOnce(fakeNimbusFeatures.newtab.onUpdate); + }); + }); + describe("#refresh", () => { + beforeEach(() => { + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + }); + it("should wait for tippytop to initialize", async () => { + feed._tippyTopProvider.initialized = false; + sinon.stub(feed._tippyTopProvider, "init").resolves(); + + await feed.refresh(); + + assert.calledOnce(feed._tippyTopProvider.init); + }); + it("should not init the tippyTopProvider if already initialized", async () => { + feed._tippyTopProvider.initialized = true; + sinon.stub(feed._tippyTopProvider, "init").resolves(); + + await feed.refresh(); + + assert.notCalled(feed._tippyTopProvider.init); + }); + it("should broadcast TOP_SITES_UPDATED", async () => { + sinon.stub(feed, "getLinksWithDefaults").returns(Promise.resolve([])); + + await feed.refresh({ broadcast: true }); + + assert.calledOnce(feed.store.dispatch); + assert.calledWithExactly( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ); + }); + it("should dispatch an action with the links returned", async () => { + await feed.refresh({ broadcast: true }); + const reference = links.map(site => + Object.assign({}, site, { + hostname: shortURLStub(site), + typedBonus: true, + }) + ); + + assert.calledOnce(feed.store.dispatch); + assert.propertyVal( + feed.store.dispatch.firstCall.args[0], + "type", + at.TOP_SITES_UPDATED + ); + assert.deepEqual( + feed.store.dispatch.firstCall.args[0].data.links, + reference + ); + }); + it("should handle empty slots in the resulting top sites array", async () => { + links = [FAKE_LINKS[0]]; + fakeNewTabUtils.pinnedLinks.links = [ + null, + null, + FAKE_LINKS[1], + null, + null, + null, + null, + null, + FAKE_LINKS[2], + ]; + await feed.refresh({ broadcast: true }); + assert.calledOnce(feed.store.dispatch); + }); + it("should dispatch AlsoToPreloaded when broadcast is false", async () => { + sandbox.stub(feed, "getLinksWithDefaults").returns([]); + await feed.refresh({ broadcast: false }); + + assert.calledOnce(feed.store.dispatch); + assert.calledWithExactly( + feed.store.dispatch, + ac.AlsoToPreloaded({ + type: at.TOP_SITES_UPDATED, + data: { links: [], pref: { collapsed: false } }, + }) + ); + }); + it("should not init storage if it is already initialized", async () => { + feed._storage.initialized = true; + + await feed.refresh({ broadcast: false }); + + assert.notCalled(feed._storage.init); + }); + it("should catch indexedDB errors", async () => { + feed._storage.get.throws(new Error()); + globals.sandbox.spy(global.console, "error"); + + try { + await feed.refresh({ broadcast: false }); + } catch (e) { + assert.fails(); + } + + assert.calledOnce(console.error); + }); + }); + describe("#updateSectionPrefs", () => { + it("should call updateSectionPrefs on UPDATE_SECTION_PREFS", () => { + sandbox.stub(feed, "updateSectionPrefs"); + + feed.onAction({ + type: at.UPDATE_SECTION_PREFS, + data: { id: "topsites" }, + }); + + assert.calledOnce(feed.updateSectionPrefs); + }); + it("should dispatch TOP_SITES_PREFS_UPDATED", async () => { + await feed.updateSectionPrefs({ collapsed: true }); + + assert.calledOnce(feed.store.dispatch); + assert.calledWithExactly( + feed.store.dispatch, + ac.BroadcastToContent({ + type: at.TOP_SITES_PREFS_UPDATED, + data: { pref: { collapsed: true } }, + }) + ); + }); + }); + describe("#getScreenshotPreview", () => { + it("should dispatch preview if request is succesful", async () => { + await feed.getScreenshotPreview("custom", 1234); + + assert.calledOnce(feed.store.dispatch); + assert.calledWithExactly( + feed.store.dispatch, + ac.OnlyToOneContent( + { + data: { preview: FAKE_SCREENSHOT, url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ); + }); + it("should return empty string if request fails", async () => { + fakeScreenshot.getScreenshotForURL = sandbox + .stub() + .returns(Promise.resolve(null)); + await feed.getScreenshotPreview("custom", 1234); + + assert.calledOnce(feed.store.dispatch); + assert.calledWithExactly( + feed.store.dispatch, + ac.OnlyToOneContent( + { + data: { preview: "", url: "custom" }, + type: at.PREVIEW_RESPONSE, + }, + 1234 + ) + ); + }); + }); + describe("#_fetchIcon", () => { + it("should reuse screenshot on the link", () => { + const link = { screenshot: "reuse.png" }; + + feed._fetchIcon(link); + + assert.notCalled(fakeScreenshot.getScreenshotForURL); + assert.propertyVal(link, "screenshot", "reuse.png"); + }); + it("should reuse existing fetching screenshot on the link", async () => { + const link = { + __sharedCache: { fetchingScreenshot: Promise.resolve("fetching.png") }, + }; + + await feed._fetchIcon(link); + + assert.notCalled(fakeScreenshot.getScreenshotForURL); + }); + it("should get a screenshot if the link is missing it", () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0])); + + assert.calledOnce(fakeScreenshot.getScreenshotForURL); + assert.calledWith(fakeScreenshot.getScreenshotForURL, FAKE_LINKS[0].url); + }); + it("should not get a screenshot if the link is missing it but top sites aren't shown", () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = false; + feed._fetchIcon(Object.assign({ __sharedCache: {} }, FAKE_LINKS[0])); + + assert.notCalled(fakeScreenshot.getScreenshotForURL); + }); + it("should update the link's cache with a screenshot", async () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + const updateLink = sandbox.stub(); + const link = { __sharedCache: { updateLink } }; + + await feed._fetchIcon(link); + + assert.calledOnce(updateLink); + assert.calledWith(updateLink, "screenshot", FAKE_SCREENSHOT); + }); + it("should skip getting a screenshot if there is a tippy top icon", () => { + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + const link = { url: "example.com" }; + feed._fetchIcon(link); + assert.propertyVal(link, "tippyTopIcon", "icon.png"); + assert.notProperty(link, "screenshot"); + assert.notCalled(fakeScreenshot.getScreenshotForURL); + }); + it("should skip getting a screenshot if there is an icon of size greater than 96x96 and no tippy top", () => { + const link = { + url: "foo.com", + favicon: "data:foo", + faviconSize: 196, + }; + feed._fetchIcon(link); + assert.notProperty(link, "tippyTopIcon"); + assert.notProperty(link, "screenshot"); + assert.notCalled(fakeScreenshot.getScreenshotForURL); + }); + it("should use the link's rich icon even if there's a tippy top", () => { + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + const link = { + url: "foo.com", + favicon: "data:foo", + faviconSize: 196, + }; + feed._fetchIcon(link); + assert.notProperty(link, "tippyTopIcon"); + }); + }); + describe("#_fetchScreenshot", () => { + it("should call maybeCacheScreenshot", async () => { + feed.store.state.Prefs.values[SHOWN_ON_NEWTAB_PREF] = true; + const updateLink = sinon.stub(); + const link = { + customScreenshotURL: "custom", + __sharedCache: { updateLink }, + }; + await feed._fetchScreenshot(link, "custom"); + + assert.calledOnce(fakeScreenshot.maybeCacheScreenshot); + assert.calledWithExactly( + fakeScreenshot.maybeCacheScreenshot, + link, + link.customScreenshotURL, + "screenshot", + sinon.match.func + ); + }); + it("should not call maybeCacheScreenshot if screenshot is set", async () => { + const updateLink = sinon.stub(); + const link = { + customScreenshotURL: "custom", + __sharedCache: { updateLink }, + screenshot: true, + }; + await feed._fetchScreenshot(link, "custom"); + + assert.notCalled(fakeScreenshot.maybeCacheScreenshot); + }); + }); + describe("#onAction", () => { + it("should call getScreenshotPreview on PREVIEW_REQUEST", () => { + sandbox.stub(feed, "getScreenshotPreview"); + + feed.onAction({ + type: at.PREVIEW_REQUEST, + data: { url: "foo" }, + meta: { fromTarget: 1234 }, + }); + + assert.calledOnce(feed.getScreenshotPreview); + assert.calledWithExactly(feed.getScreenshotPreview, "foo", 1234); + }); + it("should refresh on SYSTEM_TICK", async () => { + sandbox.stub(feed, "refresh"); + + feed.onAction({ type: at.SYSTEM_TICK }); + + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { broadcast: false }); + }); + it("should call with correct parameters on TOP_SITES_PIN", () => { + const pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: 7 }, + }; + feed.onAction(pinAction); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + pinAction.data.site, + pinAction.data.index + ); + }); + it("should call pin on TOP_SITES_PIN", () => { + sinon.stub(feed, "pin"); + const pinExistingAction = { + type: at.TOP_SITES_PIN, + data: { site: FAKE_LINKS[4], index: 4 }, + }; + + feed.onAction(pinExistingAction); + + assert.calledOnce(feed.pin); + }); + it("should trigger refresh on TOP_SITES_PIN", async () => { + sinon.stub(feed, "refresh"); + const pinExistingAction = { + type: at.TOP_SITES_PIN, + data: { site: FAKE_LINKS[4], index: 4 }, + }; + + await feed.pin(pinExistingAction); + + assert.calledOnce(feed.refresh); + }); + it("should unblock a previously blocked top site if we are now adding it manually via 'Add a Top Site' option", async () => { + const pinAction = { + type: at.TOP_SITES_PIN, + data: { site: { url: "foo.com" }, index: -1 }, + }; + feed.onAction(pinAction); + assert.calledWith(fakeNewTabUtils.blockedLinks.unblock, { + url: pinAction.data.site.url, + }); + }); + it("should call insert on TOP_SITES_INSERT", async () => { + sinon.stub(feed, "insert"); + const addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + feed.onAction(addAction); + + assert.calledOnce(feed.insert); + }); + it("should trigger refresh on TOP_SITES_INSERT", async () => { + sinon.stub(feed, "refresh"); + const addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.com" } }, + }; + + await feed.insert(addAction); + + assert.calledOnce(feed.refresh); + }); + it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => { + fakeNewTabUtils.pinnedLinks.links = [ + null, + null, + { url: "foo.com" }, + null, + null, + null, + null, + null, + FAKE_LINKS[0], + ]; + const unpinAction = { + type: at.TOP_SITES_UNPIN, + data: { site: { url: "foo.com" } }, + }; + feed.onAction(unpinAction); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.unpin, + unpinAction.data.site + ); + }); + it("should call refresh without a target if we clear history with PLACES_HISTORY_CLEARED", () => { + sandbox.stub(feed, "refresh"); + + feed.onAction({ type: at.PLACES_HISTORY_CLEARED }); + + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { broadcast: true }); + }); + it("should call refresh without a target if we remove a Topsite from history", () => { + sandbox.stub(feed, "refresh"); + + feed.onAction({ type: at.PLACES_LINKS_DELETED }); + + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { broadcast: true }); + }); + it("should still dispatch an action even if there's no target provided", async () => { + sandbox.stub(feed, "_fetchIcon"); + feed._startedUp = true; + await feed.refresh({ broadcast: true }); + assert.calledOnce(feed.store.dispatch); + assert.propertyVal( + feed.store.dispatch.firstCall.args[0], + "type", + at.TOP_SITES_UPDATED + ); + }); + it("should call init on INIT action", async () => { + sinon.stub(feed, "init"); + feed.onAction({ type: at.INIT }); + assert.calledOnce(feed.init); + }); + it("should call refresh on PLACES_LINK_BLOCKED action", async () => { + sinon.stub(feed, "refresh"); + await feed.onAction({ type: at.PLACES_LINK_BLOCKED }); + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { broadcast: true }); + }); + it("should call refresh on PLACES_LINKS_CHANGED action", async () => { + sinon.stub(feed, "refresh"); + await feed.onAction({ type: at.PLACES_LINKS_CHANGED }); + assert.calledOnce(feed.refresh); + assert.calledWithExactly(feed.refresh, { broadcast: false }); + }); + it("should call pin with correct args on TOP_SITES_INSERT without an index specified", () => { + const addAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" } }, + }; + feed.onAction(addAction); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + addAction.data.site, + 0 + ); + }); + it("should call pin with correct args on TOP_SITES_INSERT", () => { + const dropAction = { + type: at.TOP_SITES_INSERT, + data: { site: { url: "foo.bar", label: "foo" }, index: 3 }, + }; + feed.onAction(dropAction); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + dropAction.data.site, + 3 + ); + }); + it("should remove the expiration filter on UNINIT", () => { + feed.onAction({ type: "UNINIT" }); + + assert.calledOnce(fakePageThumbs.removeExpirationFilter); + }); + it("should call updatePinnedSearchShortcuts on UPDATE_PINNED_SEARCH_SHORTCUTS action", async () => { + sinon.stub(feed, "updatePinnedSearchShortcuts"); + const addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + await feed.onAction({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { addedShortcuts }, + }); + assert.calledOnce(feed.updatePinnedSearchShortcuts); + }); + it("should refresh from Contile on SHOW_SPONSORED_PREF if Contile is enabled", () => { + sandbox.spy(feed._contile, "refresh"); + const prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF }, + }; + fakeNimbusFeatures.newtab.getVariable.returns(true); + feed.onAction(prefChangeAction); + + assert.calledOnce(feed._contile.refresh); + }); + it("should not refresh from Contile on SHOW_SPONSORED_PREF if Contile is disabled", () => { + sandbox.spy(feed._contile, "refresh"); + const prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF }, + }; + fakeNimbusFeatures.newtab.getVariable.returns(false); + feed.onAction(prefChangeAction); + + assert.notCalled(feed._contile.refresh); + }); + it("should reset Contile cache prefs when SHOW_SPONSORED_PREF is false", () => { + Services.prefs.setStringPref(CONTILE_CACHE_PREF, "[]"); + Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, 15 * 60 * 1000); + Services.prefs.setIntPref(CONTILE_CACHE_VALID_FOR_PREF, Date.now()); + + sandbox.spy(feed._contile, "refresh"); + const prefChangeAction = { + type: at.PREF_CHANGED, + data: { name: SHOW_SPONSORED_PREF, value: false }, + }; + fakeNimbusFeatures.newtab.getVariable.returns(true); + feed.onAction(prefChangeAction); + + assert.calledOnce(feed._contile.refresh); + + // cached pref values should have reset + assert.isUndefined(Services.prefs.getStringPref(CONTILE_CACHE_PREF)); + assert.isUndefined( + Services.prefs.getIntPref(CONTILE_CACHE_LAST_FETCH_PREF) + ); + assert.isUndefined( + Services.prefs.getIntPref(CONTILE_CACHE_VALID_FOR_PREF) + ); + }); + }); + describe("#add", () => { + it("should pin site in first slot of empty pinned list", () => { + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + }); + it("should pin site in first slot of pinned list with empty first slot", () => { + fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + }); + it("should move a pinned site in first slot to the next slot: part 1", () => { + const site1 = { url: "example.com" }; + fakeNewTabUtils.pinnedLinks.links = [site1]; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { site } }); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); + }); + it("should move a pinned site in first slot to the next slot: part 2", () => { + const site1 = { url: "example.com" }; + const site2 = { url: "example.org" }; + fakeNewTabUtils.pinnedLinks.links = [site1, null, site2]; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { site } }); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); + }); + it("should unpin the last site if all slots are already pinned", () => { + const site1 = { url: "example.com" }; + const site2 = { url: "example.org" }; + const site3 = { url: "example.net" }; + const site4 = { url: "example.biz" }; + const site5 = { url: "example.info" }; + const site6 = { url: "example.news" }; + const site7 = { url: "example.lol" }; + const site8 = { url: "example.golf" }; + fakeNewTabUtils.pinnedLinks.links = [ + site1, + site2, + site3, + site4, + site5, + site6, + site7, + site8, + ]; + feed.store.state.Prefs.values.topSitesRows = 1; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { site } }); + assert.equal(fakeNewTabUtils.pinnedLinks.pin.callCount, 8); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 1); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 2); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site3, 3); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site4, 4); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site5, 5); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site6, 6); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site7, 7); + }); + }); + describe("#pin", () => { + it("should pin site in specified slot empty pinned list", async () => { + const site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + await feed.pin({ data: { index: 2, site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); + }); + it("should lookup the link object to update the custom screenshot", async () => { + const site = { + url: "foo.bar", + label: "foo", + customScreenshotURL: "screenshot", + }; + sandbox.spy(feed.pinnedCache, "request"); + + await feed.pin({ data: { index: 2, site } }); + + assert.calledOnce(feed.pinnedCache.request); + }); + it("should lookup the link object to update the custom screenshot", async () => { + const site = { url: "foo.bar", label: "foo", customScreenshotURL: null }; + sandbox.spy(feed.pinnedCache, "request"); + + await feed.pin({ data: { index: 2, site } }); + + assert.calledOnce(feed.pinnedCache.request); + }); + it("should not do a link object lookup if custom screenshot field is not set", async () => { + const site = { url: "foo.bar", label: "foo" }; + sandbox.spy(feed.pinnedCache, "request"); + + await feed.pin({ data: { index: 2, site } }); + + assert.notCalled(feed.pinnedCache.request); + }); + it("should pin site in specified slot of pinned list that is free", () => { + fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo" }; + feed.pin({ data: { index: 2, site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); + }); + it("should save the searchTopSite attribute if set", () => { + fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo", searchTopSite: true }; + feed.pin({ data: { index: 2, site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.propertyVal( + fakeNewTabUtils.pinnedLinks.pin.firstCall.args[0], + "searchTopSite", + true + ); + }); + it("should NOT move a pinned site in specified slot to the next slot", () => { + fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo" }; + feed.pin({ data: { index: 2, site } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); + }); + it("should properly update LinksCache object properties between migrations", async () => { + fakeNewTabUtils.pinnedLinks.links = [{ url: "https://foo.com/" }]; + + let pinnedLinks = await feed.pinnedCache.request(); + assert.equal(pinnedLinks.length, 1); + feed.pinnedCache.expire(); + pinnedLinks[0].__sharedCache.updateLink("screenshot", "foo"); + + pinnedLinks = await feed.pinnedCache.request(); + assert.propertyVal(pinnedLinks[0], "screenshot", "foo"); + + // Force cache expiration in order to trigger a migration of objects + feed.pinnedCache.expire(); + pinnedLinks[0].__sharedCache.updateLink("screenshot", "bar"); + + pinnedLinks = await feed.pinnedCache.request(); + assert.propertyVal(pinnedLinks[0], "screenshot", "bar"); + }); + it("should call insert if index < 0", () => { + const site = { url: "foo.bar", label: "foo" }; + const action = { data: { index: -1, site } }; + + sandbox.spy(feed, "insert"); + feed.pin(action); + + assert.calledOnce(feed.insert); + assert.calledWithExactly(feed.insert, action); + }); + it("should not call insert if index == 0", () => { + const site = { url: "foo.bar", label: "foo" }; + const action = { data: { index: 0, site } }; + + sandbox.spy(feed, "insert"); + feed.pin(action); + + assert.notCalled(feed.insert); + }); + }); + describe("clearLinkCustomScreenshot", () => { + it("should remove cached screenshot if custom url changes", async () => { + const stub = sandbox.stub(); + sandbox.stub(feed.pinnedCache, "request").returns( + Promise.resolve([ + { + url: "foo", + customScreenshotURL: "old_screenshot", + __sharedCache: { updateLink: stub }, + }, + ]) + ); + + await feed._clearLinkCustomScreenshot({ + url: "foo", + customScreenshotURL: "new_screenshot", + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, "screenshot", undefined); + }); + it("should remove cached screenshot if custom url is removed", async () => { + const stub = sandbox.stub(); + sandbox.stub(feed.pinnedCache, "request").returns( + Promise.resolve([ + { + url: "foo", + customScreenshotURL: "old_screenshot", + __sharedCache: { updateLink: stub }, + }, + ]) + ); + + await feed._clearLinkCustomScreenshot({ + url: "foo", + customScreenshotURL: "new_screenshot", + }); + + assert.calledOnce(stub); + assert.calledWithExactly(stub, "screenshot", undefined); + }); + }); + describe("#drop", () => { + it("should correctly handle different index values", () => { + let index = -1; + const site = { url: "foo.bar", label: "foo" }; + const action = { data: { index, site } }; + + feed.insert(action); + + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + + index = undefined; + feed.insert(action); + + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 0); + }); + it("should pin site in specified slot that is free", () => { + fakeNewTabUtils.pinnedLinks.links = [null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { index: 2, site, draggedFromIndex: 0 } }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); + }); + it("should move a pinned site in specified slot to the next slot", () => { + fakeNewTabUtils.pinnedLinks.links = [null, null, { url: "example.com" }]; + const site = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { index: 2, site, draggedFromIndex: 3 } }); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + { url: "example.com" }, + 3 + ); + }); + it("should move pinned sites in the direction of the dragged site", () => { + const site1 = { url: "foo.bar", label: "foo" }; + const site2 = { url: "example.com", label: "example" }; + fakeNewTabUtils.pinnedLinks.links = [null, null, site2]; + feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 0 } }); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 1); + fakeNewTabUtils.pinnedLinks.pin.resetHistory(); + feed.insert({ data: { index: 2, site: site1, draggedFromIndex: 5 } }); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site1, 2); + assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site2, 3); + }); + it("should not insert past the visible top sites", () => { + const site1 = { url: "foo.bar", label: "foo" }; + feed.insert({ data: { index: 42, site: site1, draggedFromIndex: 0 } }); + assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); + }); + }); + describe("integration", () => { + let resolvers = []; + beforeEach(() => { + feed.store.dispatch = sandbox.stub().callsFake(() => { + resolvers.shift()(); + }); + feed._startedUp = true; + sandbox.stub(feed, "_fetchScreenshot"); + }); + afterEach(() => { + sandbox.restore(); + }); + + const forDispatch = action => + new Promise(resolve => { + resolvers.push(resolve); + feed.onAction(action); + }); + + it("should add a pinned site and remove it", async () => { + feed._requestRichIcon = sinon.stub(); + const url = "https://pin.me"; + fakeNewTabUtils.pinnedLinks.pin = sandbox.stub().callsFake(link => { + fakeNewTabUtils.pinnedLinks.links.push(link); + }); + + await forDispatch({ type: at.TOP_SITES_INSERT, data: { site: { url } } }); + fakeNewTabUtils.pinnedLinks.links.pop(); + await forDispatch({ type: at.PLACES_LINK_BLOCKED }); + + assert.calledTwice(feed.store.dispatch); + assert.equal( + feed.store.dispatch.firstCall.args[0].data.links[0].url, + url + ); + assert.equal( + feed.store.dispatch.secondCall.args[0].data.links[0].url, + FAKE_LINKS[0].url + ); + }); + }); + + describe("improvesearch.noDefaultSearchTile experiment", () => { + const NO_DEFAULT_SEARCH_TILE_PREF = "improvesearch.noDefaultSearchTile"; + beforeEach(() => { + global.Services.search.getDefault = async () => ({ + identifier: "google", + searchForm: "google.com", + }); + feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = true; + }); + it("should filter out alexa top 5 search from the default sites", async () => { + const TOP_5_TEST = [ + "google.com", + "search.yahoo.com", + "yahoo.com", + "bing.com", + "ask.com", + "duckduckgo.com", + ]; + links = [{ url: "amazon.com" }, ...TOP_5_TEST.map(url => ({ url }))]; + const urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + assert.include(urlsReturned, "amazon.com"); + TOP_5_TEST.forEach(url => assert.notInclude(urlsReturned, url)); + }); + it("should not filter out alexa, default search from the query results if the experiment pref is off", async () => { + links = [ + { url: "google.com" }, + { url: "foo.com" }, + { url: "duckduckgo" }, + ]; + feed.store.state.Prefs.values[NO_DEFAULT_SEARCH_TILE_PREF] = false; + const urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + assert.include(urlsReturned, "google.com"); + }); + it("should filter out the current default search from the default sites", async () => { + feed._currentSearchHostname = "amazon"; + feed.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + links = [{ url: "foo.com" }]; + const urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + assert.notInclude(urlsReturned, "amazon.com"); + }); + it("should not filter out current default search from pinned sites even if it matches the current default search", async () => { + links = [{ url: "foo.com" }]; + fakeNewTabUtils.pinnedLinks.links = [{ url: "google.com" }]; + const urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + assert.include(urlsReturned, "google.com"); + }); + it("should call refresh and set ._currentSearchHostname to the new engine hostname when the the default search engine has been set", () => { + sinon.stub(feed, "refresh"); + sandbox + .stub(global.Services.search, "defaultEngine") + .value({ identifier: "ddg", searchForm: "duckduckgo.com" }); + feed.observe(null, "browser-search-engine-modified", "engine-default"); + assert.equal(feed._currentSearchHostname, "duckduckgo"); + assert.calledOnce(feed.refresh); + }); + it("should call refresh when the experiment pref has changed", () => { + sinon.stub(feed, "refresh"); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: true }, + }); + assert.calledOnce(feed.refresh); + + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: NO_DEFAULT_SEARCH_TILE_PREF, value: false }, + }); + assert.calledTwice(feed.refresh); + }); + }); + + describe("improvesearch.topSitesSearchShortcuts", () => { + beforeEach(() => { + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = true; + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF] = + "google,amazon"; + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; + const searchEngines = [ + { aliases: ["@google"] }, + { aliases: ["@amazon"] }, + ]; + global.Services.search.getAppProvidedEngines = async () => searchEngines; + fakeNewTabUtils.pinnedLinks.pin = sinon + .stub() + .callsFake((site, index) => { + fakeNewTabUtils.pinnedLinks.links[index] = site; + }); + }); + + it("should properly disable search improvements if the pref is off", async () => { + sandbox.stub(global.Services.prefs, "clearUserPref"); + sandbox.spy(feed.pinnedCache, "expire"); + sandbox.spy(feed, "refresh"); + + // an actual implementation of unpin (until we can get a mochitest for search improvements) + fakeNewTabUtils.pinnedLinks.unpin = sinon.stub().callsFake(site => { + let index = -1; + for (let i = 0; i < fakeNewTabUtils.pinnedLinks.links.length; i++) { + let link = fakeNewTabUtils.pinnedLinks.links[i]; + if (link && link.url === site.url) { + index = i; + } + } + if (index > -1) { + fakeNewTabUtils.pinnedLinks.links[index] = null; + } + }); + + // ensure we've inserted search shorcuts + pin an additional site in space 4 + await feed._maybeInsertSearchShortcuts(fakeNewTabUtils.pinnedLinks.links); + fakeNewTabUtils.pinnedLinks.pin({ url: "https://dontunpinme.com" }, 3); + + // turn the experiment off + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: false }, + }); + + // check we cleared the pref, expired the pinned cache, and refreshed the feed + assert.calledWith( + global.Services.prefs.clearUserPref, + `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}` + ); + assert.calledOnce(feed.pinnedCache.expire); + assert.calledWith(feed.refresh, { broadcast: true }); + + // check that the search shortcuts were removed from the list of pinned sites + const urlsReturned = fakeNewTabUtils.pinnedLinks.links + .filter(s => s) + .map(link => link.url); + assert.notInclude(urlsReturned, "https://amazon.com"); + assert.notInclude(urlsReturned, "https://google.com"); + assert.include(urlsReturned, "https://dontunpinme.com"); + + // check that the positions where the search shortcuts were null, and the additional pinned site is untouched in space 4 + assert.equal(fakeNewTabUtils.pinnedLinks.links[0], null); + assert.equal(fakeNewTabUtils.pinnedLinks.links[1], null); + assert.equal(fakeNewTabUtils.pinnedLinks.links[2], undefined); + assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { + url: "https://dontunpinme.com", + }); + }); + + it("should updateCustomSearchShortcuts when experiment pref is turned on", async () => { + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + feed.updateCustomSearchShortcuts = sinon.spy(); + + // turn the experiment on + feed.onAction({ + type: at.PREF_CHANGED, + data: { name: SEARCH_SHORTCUTS_EXPERIMENT_PREF, value: true }, + }); + + assert.calledOnce(feed.updateCustomSearchShortcuts); + }); + + it("should filter out default top sites that match a hostname of a search shortcut if previously blocked", async () => { + feed.refreshDefaults("https://amazon.ca"); + fakeNewTabUtils.blockedLinks.links = [{ url: "https://amazon.com" }]; + fakeNewTabUtils.blockedLinks.isBlocked = site => + fakeNewTabUtils.blockedLinks.links[0].url === site.url; + const urlsReturned = (await feed.getLinksWithDefaults()).map( + link => link.url + ); + assert.notInclude(urlsReturned, "https://amazon.ca"); + }); + + it("should update frecent search topsite icon", async () => { + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + links = [{ url: "google.com" }]; + + const urlsReturned = await feed.getLinksWithDefaults(); + + const defaultSearchTopsite = urlsReturned.find( + s => s.url === "google.com" + ); + assert.propertyVal(defaultSearchTopsite, "searchTopSite", true); + assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + }); + it("should update default search topsite icon", async () => { + feed._tippyTopProvider.processSite = site => { + site.tippyTopIcon = "icon.png"; + site.backgroundColor = "#fff"; + return site; + }; + links = [{ url: "foo.com" }]; + feed.onAction({ + type: at.PREFS_INITIAL_VALUES, + data: { "default.sites": "google.com,amazon.com" }, + }); + + const urlsReturned = await feed.getLinksWithDefaults(); + + const defaultSearchTopsite = urlsReturned.find( + s => s.url === "amazon.com" + ); + assert.propertyVal(defaultSearchTopsite, "searchTopSite", true); + assert.equal(defaultSearchTopsite.tippyTopIcon, "icon.png"); + assert.equal(defaultSearchTopsite.backgroundColor, "#fff"); + }); + it("should dispatch UPDATE_SEARCH_SHORTCUTS on updateCustomSearchShortcuts", async () => { + feed.store.state.Prefs.values["improvesearch.noDefaultSearchTile"] = true; + await feed.updateCustomSearchShortcuts(); + assert.calledOnce(feed.store.dispatch); + assert.calledWith(feed.store.dispatch, { + data: { + searchShortcuts: [ + { + keyword: "@google", + shortURL: "google", + url: "https://google.com", + }, + { + keyword: "@amazon", + shortURL: "amazon", + url: "https://amazon.com", + }, + ], + }, + meta: { + from: "ActivityStream:Main", + to: "ActivityStream:Content", + isStartup: false, + }, + type: "UPDATE_SEARCH_SHORTCUTS", + }); + }); + + describe("_maybeInsertSearchShortcuts", () => { + beforeEach(() => { + // Default is one row + feed.store.state.Prefs.values.topSitesRows = TOP_SITES_DEFAULT_ROWS; + // Eight slots per row + fakeNewTabUtils.pinnedLinks.links = [ + { url: "" }, + { url: "" }, + { url: "" }, + null, + { url: "" }, + { url: "" }, + null, + { url: "" }, + ]; + }); + + it("should be called on getLinksWithDefaults", async () => { + sandbox.spy(feed, "_maybeInsertSearchShortcuts"); + await feed.getLinksWithDefaults(); + assert.calledOnce(feed._maybeInsertSearchShortcuts); + }); + + it("should do nothing and return false if the experiment is disabled", async () => { + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT_PREF] = false; + assert.isFalse( + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ) + ); + assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); + }); + + it("should pin shortcuts in the correct order, into the available unpinned slots", async () => { + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + // The shouldPin pref is "google,amazon" so expect the shortcuts in that order + assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { + url: "https://google.com", + searchTopSite: true, + label: "@google", + }); + assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[6], { + url: "https://amazon.com", + searchTopSite: true, + label: "@amazon", + }); + }); + + it("should not pin shortcuts for the current default search engine", async () => { + feed._currentSearchHostname = "google"; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.deepEqual(fakeNewTabUtils.pinnedLinks.links[3], { + url: "https://amazon.com", + searchTopSite: true, + label: "@amazon", + }); + }); + + it("should only pin the first shortcut if there's only one available slot", async () => { + fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + // The first item in the shouldPin pref is "google" so expect only Google to be pinned + assert.ok( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://google.com" + ) + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + }); + + it("should pin none if there's no available slot", async () => { + fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; + fakeNewTabUtils.pinnedLinks.links[6] = { url: "" }; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://google.com" + ) + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + }); + + it("should not pin a shortcut if the corresponding search engine is not available", async () => { + // Make Amazon search engine unavailable + global.Services.search.getAppProvidedEngines = async () => [ + { aliases: ["@google"] }, + ]; + fakeNewTabUtils.pinnedLinks.links.fill(null); + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + }); + + it("should not pin a search shortcut if it's been pinned before", async () => { + fakeNewTabUtils.pinnedLinks.links.fill(null); + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = + "google,amazon"; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://google.com" + ) + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + + fakeNewTabUtils.pinnedLinks.links.fill(null); + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = + "amazon"; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.ok( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://google.com" + ) + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + + fakeNewTabUtils.pinnedLinks.links.fill(null); + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = + "google"; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.notOk( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://google.com" + ) + ); + assert.ok( + fakeNewTabUtils.pinnedLinks.links.find( + s => s && s.url === "https://amazon.com" + ) + ); + }); + + it("should record the insertion of a search shortcut", async () => { + feed.store.state.Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF] = ""; + // Fill up one slot, so there's only one left - to be filled by Google + fakeNewTabUtils.pinnedLinks.links[3] = { url: "" }; + await feed._maybeInsertSearchShortcuts( + fakeNewTabUtils.pinnedLinks.links + ); + assert.calledWithExactly(feed.store.dispatch, { + data: { name: SEARCH_SHORTCUTS_HAVE_PINNED_PREF, value: "google" }, + meta: { from: "ActivityStream:Content", to: "ActivityStream:Main" }, + type: "SET_PREF", + }); + }); + }); + }); + + describe("updatePinnedSearchShortcuts", () => { + it("should unpin a shortcut in deletedShortcuts", () => { + const deletedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + const addedShortcuts = []; + fakeNewTabUtils.pinnedLinks.links = [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + assert.notCalled(fakeNewTabUtils.pinnedLinks.pin); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); + assert.calledWith(fakeNewTabUtils.pinnedLinks.unpin, { + url: "https://google.com", + }); + }); + + it("should pin a shortcut in addedShortcuts", () => { + const addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + const deletedShortcuts = []; + fakeNewTabUtils.pinnedLinks.links = [ + null, + null, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + { + label: "google", + searchTopSite: true, + searchVendor: "google", + url: "https://google.com", + }, + 0 + ); + }); + + it("should pin and unpin in the same action", () => { + const addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + { + url: "https://ebay.com", + searchVendor: "ebay", + label: "ebay", + searchTopSite: true, + }, + ]; + const deletedShortcuts = [ + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + fakeNewTabUtils.pinnedLinks.links = [ + { url: "https://foo.com" }, + { + url: "https://amazon.com", + searchVendor: "amazon", + label: "amazon", + searchTopSite: true, + }, + ]; + feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + assert.calledOnce(fakeNewTabUtils.pinnedLinks.unpin); + assert.calledTwice(fakeNewTabUtils.pinnedLinks.pin); + }); + + it("should pin a shortcut in addedShortcuts even if pinnedLinks is full", () => { + const addedShortcuts = [ + { + url: "https://google.com", + searchVendor: "google", + label: "google", + searchTopSite: true, + }, + ]; + const deletedShortcuts = []; + fakeNewTabUtils.pinnedLinks.links = FAKE_LINKS; + feed.updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }); + assert.notCalled(fakeNewTabUtils.pinnedLinks.unpin); + assert.calledWith( + fakeNewTabUtils.pinnedLinks.pin, + { label: "google", searchTopSite: true, url: "https://google.com" }, + 0 + ); + }); + }); + + describe("#_attachTippyTopIconForSearchShortcut", () => { + beforeEach(() => { + feed._tippyTopProvider.processSite = site => { + if (site.url === "https://www.yandex.ru/") { + site.tippyTopIcon = "yandex-ru.png"; + site.smallFavicon = "yandex-ru.ico"; + } else if ( + site.url === "https://www.yandex.com/" || + site.url === "https://yandex.com" + ) { + site.tippyTopIcon = "yandex.png"; + site.smallFavicon = "yandex.ico"; + } else { + site.tippyTopIcon = "google.png"; + site.smallFavicon = "google.ico"; + } + return site; + }; + }); + + it("should choose the -ru icons for Yandex search shortcut", async () => { + sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ + wrappedJSObject: { _searchForm: "https://www.yandex.ru/" }, + }); + + const link = { url: "https://yandex.com" }; + await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); + + assert.equal(link.tippyTopIcon, "yandex-ru.png"); + assert.equal(link.smallFavicon, "yandex-ru.ico"); + assert.equal(link.url, "https://yandex.com"); + }); + + it("should choose -com icons for Yandex search shortcut", async () => { + sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ + wrappedJSObject: { _searchForm: "https://www.yandex.com/" }, + }); + + const link = { url: "https://yandex.com" }; + await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); + + assert.equal(link.tippyTopIcon, "yandex.png"); + assert.equal(link.smallFavicon, "yandex.ico"); + assert.equal(link.url, "https://yandex.com"); + }); + + it("should use the -com icons if can't fetch the search form URL", async () => { + sandbox.stub(global.Services.search, "getEngineByAlias").resolves(null); + + const link = { url: "https://yandex.com" }; + await feed._attachTippyTopIconForSearchShortcut(link, "@yandex"); + + assert.equal(link.tippyTopIcon, "yandex.png"); + assert.equal(link.smallFavicon, "yandex.ico"); + assert.equal(link.url, "https://yandex.com"); + }); + + it("should choose the correct icon for other non-yandex search shortcut", async () => { + sandbox.stub(global.Services.search, "getEngineByAlias").resolves({ + wrappedJSObject: { _searchForm: "https://www.google.com/" }, + }); + + const link = { url: "https://google.com" }; + await feed._attachTippyTopIconForSearchShortcut(link, "@google"); + + assert.equal(link.tippyTopIcon, "google.png"); + assert.equal(link.smallFavicon, "google.ico"); + assert.equal(link.url, "https://google.com"); + }); + }); + + describe("#ContileIntegration", () => { + let getStringPrefStub; + let getIntPrefStub; + beforeEach(() => { + // Turn on sponsored TopSites for testing + feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + fetchStub = sandbox.stub(); + globals.set("fetch", fetchStub); + + getStringPrefStub = sandbox.stub(global.Services.prefs, "getStringPref"); + getStringPrefStub + .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) + .returns(`["foo","bar"]`); + + getIntPrefStub = sandbox.stub(global.Services.prefs, "getIntPref"); + + fakeNimbusFeatures.newtab.getVariable.returns(true); + sandbox.spy(global.Services.prefs, "setStringPref"); + sandbox.spy(global.Services.prefs, "setIntPref"); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should fetch sites from Contile", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + assert.equal(feed._contile.sites.length, 2); + }); + + it("should fetch SOV (Share-of-Voice) settings from Contile", async () => { + const sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "foo", + percentage: 100, + }, + { + partner: "bar", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "foo", + percentage: 80, + }, + { + partner: "bar", + percentage: 20, + }, + ], + }, + ], + }; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + sov: btoa(JSON.stringify(sov)), + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + assert.deepEqual(feed._contile.sov, sov); + assert.equal(feed._contile.sites.length, 2); + }); + + it("should not fetch from Contile if it's not enabled", async () => { + fakeNimbusFeatures.newtab.getVariable.reset(); + fakeNimbusFeatures.newtab.getVariable.returns(false); + const fetched = await feed._contile._fetchSites(); + + assert.notCalled(fetchStub); + assert.ok(!fetched); + assert.equal(feed._contile.sites.length, 0); + }); + + it("should still return two tiles when Contile provides more than 2 tiles and filtering results in more than 2 tiles", async () => { + fakeNimbusFeatures.newtab.getVariable.reset(); + fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true); + fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(true); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + // Both "foo" and "bar" should be filtered + assert.equal(feed._contile.sites.length, 2); + assert.equal(feed._contile.sites[0].url, "https://www.test.com"); + assert.equal(feed._contile.sites[1].url, "https://test1.com"); + }); + + it("should still return two tiles with replacement if the Nimbus variable was unset", async () => { + fakeNimbusFeatures.newtab.getVariable.reset(); + fakeNimbusFeatures.newtab.getVariable.onCall(0).returns(true); + fakeNimbusFeatures.newtab.getVariable.onCall(1).returns(undefined); + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + assert.equal(feed._contile.sites.length, 2); + assert.equal(feed._contile.sites[0].url, "https://www.test.com"); + assert.equal(feed._contile.sites[1].url, "https://test1.com"); + }); + + it("should filter the blocked sponsors", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + // Both "foo" and "bar" should be filtered + assert.equal(feed._contile.sites.length, 1); + assert.equal(feed._contile.sites[0].url, "https://www.test.com"); + }); + + it("should return false when Contile returns with error status and no values are stored in cache prefs", async () => { + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(!fetched); + assert.ok(!feed._contile.sites.length); + }); + + it("should return false when Contile returns with error status and cached tiles are expried", async () => { + getIntPrefStub + .withArgs(CONTILE_CACHE_VALID_FOR_PREF) + .returns(1000 * 60 * 15); + getIntPrefStub + .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) + .returns(Date.now() - 1000 * 60 * 30); + + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(!fetched); + assert.ok(!feed._contile.sites.length); + }); + + it("should handle invalid payload properly from Contile", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + unknown: [], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(!fetched); + assert.ok(!feed._contile.sites.length); + }); + + it("should handle empty payload properly from Contile", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + assert.ok(!feed._contile.sites.length); + }); + + it("should handle no content properly from Contile", async () => { + fetchStub.resolves({ ok: true, status: 204 }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(!fetched); + assert.ok(!feed._contile.sites.length); + }); + + it("should set Caching Prefs after a sucessful request", async () => { + const tiles = [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles, + }), + }); + + const fetched = await feed._contile._fetchSites(); + assert.ok(fetched); + assert.calledOnce(Services.prefs.setStringPref); + assert.calledTwice(Services.prefs.setIntPref); + + assert.calledWith( + Services.prefs.setStringPref, + CONTILE_CACHE_PREF, + JSON.stringify(tiles) + ); + assert.calledWith( + Services.prefs.setIntPref, + CONTILE_CACHE_VALID_FOR_PREF, + 11322 + ); + }); + + it("should return cached valid tiles when Contile returns error status", async () => { + const tiles = [ + { + url: "https://www.test-cached.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + + getStringPrefStub + .withArgs(CONTILE_CACHE_PREF) + .returns(JSON.stringify(tiles)); + + // valid for 15 mins + getIntPrefStub + .withArgs(CONTILE_CACHE_VALID_FOR_PREF) + .returns(1000 * 60 * 15); + getIntPrefStub + .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) + .returns(Date.now()); + + fetchStub.resolves({ + status: 304, + }); + + const fetched = await feed._contile._fetchSites(); + assert.ok(fetched); + assert.equal(feed._contile.sites.length, 2); + assert.equal(feed._contile.sites[0].url, "https://www.test-cached.com"); + assert.equal(feed._contile.sites[1].url, "https://www.test1-cached.com"); + }); + + it("should not be successful when contile returns an error and no valid tiles are cached", async () => { + getStringPrefStub.withArgs(CONTILE_CACHE_PREF).returns("[]"); + + getIntPrefStub.withArgs(CONTILE_CACHE_VALID_FOR_PREF).returns(0); + getIntPrefStub.withArgs(CONTILE_CACHE_LAST_FETCH_PREF).returns(0); + + fetchStub.resolves({ + status: 500, + }); + + const fetched = await feed._contile._fetchSites(); + assert.ok(!fetched); + }); + + it("should return cached valid tiles filtering blocked tiles when Contile returns error status", async () => { + const tiles = [ + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://www.test1-cached.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]; + getStringPrefStub + .withArgs(CONTILE_CACHE_PREF) + .returns(JSON.stringify(tiles)); + + // valid for 15 mins + getIntPrefStub + .withArgs(CONTILE_CACHE_VALID_FOR_PREF) + .returns(1000 * 60 * 15); + getIntPrefStub + .withArgs(CONTILE_CACHE_LAST_FETCH_PREF) + .returns(Date.now()); + + fetchStub.resolves({ + status: 304, + }); + + const fetched = await feed._contile._fetchSites(); + assert.ok(fetched); + assert.equal(feed._contile.sites.length, 1); + assert.equal(feed._contile.sites[0].url, "https://www.test1-cached.com"); + }); + + it("should still return 3 tiles when nimbus variable overrides max num of sponsored contile tiles", async () => { + fakeNimbusFeatures.pocketNewtab.getVariable.returns(3); + + fetchStub.resolves({ + ok: true, + status: 200, + headers: new Map([ + ["cache-control", "private, max-age=859, stale-if-error=10463"], + ]), + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + { + url: "https://test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + assert.equal(feed._contile.sites.length, 3); + assert.equal(feed._contile.sites[0].url, "https://www.test.com"); + assert.equal(feed._contile.sites[1].url, "https://test1.com"); + assert.equal(feed._contile.sites[2].url, "https://test2.com"); + }); + }); + + describe("#_mergeSponsoredLinks", () => { + let fakeSponsoredLinks; + let sov; + beforeEach(() => { + fakeSponsoredLinks = { + amp: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + partner: "amp", + sponsored_position: 1, + }, + { + url: "https://www.test1.com", + image_url: "images/test1-com.png", + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + partner: "amp", + sponsored_position: 2, + }, + { + url: "https://www.test2.com", + image_url: "images/test2-com.png", + click_url: "https://www.test2-click.com", + impression_url: "https://www.test2-impression.com", + name: "test2", + partner: "amp", + sponsored_position: 2, + }, + ], + "moz-sales": [ + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + partner: "moz-sales", + pos: 2, + }, + ], + }; + + sov = { + name: "SOV-20230518215316", + allocations: [ + { + position: 1, + allocation: [ + { + partner: "amp", + percentage: 100, + }, + { + partner: "moz-sales", + percentage: 0, + }, + ], + }, + { + position: 2, + allocation: [ + { + partner: "amp", + percentage: 80, + }, + { + partner: "moz-sales", + percentage: 20, + }, + ], + }, + ], + }; + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should join sponsored links if the sov object is absent", async () => { + sandbox.stub(feed._contile, "sov").get(() => null); + + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat()); + }); + + it("should join sponosred links if the SOV Nimbus variable is disabled", async () => { + fakeNimbusFeatures.pocketNewtab.getVariable.returns(false); + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.deepEqual(sponsored, Object.values(fakeSponsoredLinks).flat()); + }); + + it("should pick sponsored links based on sov configurations", async () => { + sandbox.stub(feed._contile, "sov").get(() => sov); + fakeNimbusFeatures.pocketNewtab.getVariable.reset(); + fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true); + fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(undefined); + global.Sampling.ratioSample.onCall(0).resolves(0); + global.Sampling.ratioSample.onCall(1).resolves(1); + + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.equal(sponsored.length, 2); + assert.equal(sponsored[0].partner, "amp"); + assert.equal(sponsored[0].sponsored_position, 1); + assert.equal(sponsored[1].partner, "moz-sales"); + assert.equal(sponsored[1].sponsored_position, 2); + assert.equal(sponsored[1].pos, 1); + }); + + it("should add remaining contile tiles when nimbus var contile max num sponsored is present", async () => { + sandbox.stub(feed._contile, "sov").get(() => sov); + fakeNimbusFeatures.pocketNewtab.getVariable.reset(); + fakeNimbusFeatures.pocketNewtab.getVariable.onCall(0).returns(true); + fakeNimbusFeatures.pocketNewtab.getVariable.onCall(1).returns(3); + global.Sampling.ratioSample.resolves(0); + + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.equal(sponsored.length, 3); + }); + + it("should fall back to other partners if the chosen partner does not have any links", async () => { + sandbox.stub(feed._contile, "sov").get(() => sov); + fakeNimbusFeatures.pocketNewtab.getVariable.returns(true); + global.Sampling.ratioSample.onCall(0).resolves(0); + global.Sampling.ratioSample.onCall(1).resolves(0); + + fakeSponsoredLinks.amp = []; + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.equal(sponsored.length, 1); + assert.equal(sponsored[0].partner, "moz-sales"); + assert.equal(sponsored[0].sponsored_position, 1); + assert.equal(sponsored[0].pos, 0); + }); + + it("should return an empty array if none of the partners have links", async () => { + sandbox.stub(feed._contile, "sov").get(() => sov); + fakeNimbusFeatures.pocketNewtab.getVariable.returns(true); + global.Sampling.ratioSample.onCall(0).resolves(0); + global.Sampling.ratioSample.onCall(1).resolves(0); + + fakeSponsoredLinks.amp = []; + fakeSponsoredLinks["moz-sales"] = []; + const sponsored = await feed._mergeSponsoredLinks(fakeSponsoredLinks); + + assert.equal(sponsored.length, 0); + }); + }); + + describe("#_readDefaults", () => { + beforeEach(() => { + // Turn on sponsored TopSites for testing + feed.store.state.Prefs.values[SHOW_SPONSORED_PREF] = true; + fetchStub = sandbox.stub(); + globals.set("fetch", fetchStub); + fetchStub.resolves({ ok: true, status: 204 }); + sandbox + .stub(global.Services.prefs, "getBoolPref") + .withArgs(REMOTE_SETTING_DEFAULTS_PREF) + .returns(true); + + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) + .returns(`["foo","bar"]`); + sandbox.stub(global.Services.prefs, "prefIsLocked").returns(false); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should filter all blocked sponsored tiles from RemoteSettings when Contile is disabled", async () => { + sandbox.stub(feed, "_getRemoteConfig").resolves([ + { url: "https://foo.com", title: "foo", sponsored_position: 1 }, + { url: "https://bar.com", title: "bar", sponsored_position: 2 }, + { url: "https://test.com", title: "test", sponsored_position: 3 }, + ]); + fakeNimbusFeatures.newtab.getVariable.returns(false); + await feed._readDefaults(); + + assert.equal(DEFAULT_TOP_SITES.length, 1); + assert.equal(DEFAULT_TOP_SITES[0].label, "test"); + }); + + it("should also filter all blocked sponsored tiles from RemoteSettings when Contile is enabled", async () => { + sandbox.stub(feed, "_getRemoteConfig").resolves([ + { url: "https://foo.com", title: "foo", sponsored_position: 1 }, + { url: "https://bar.com", title: "bar", sponsored_position: 2 }, + { url: "https://test.com", title: "test", sponsored_position: 3 }, + ]); + fakeNimbusFeatures.newtab.getVariable.returns(true); + + await feed._readDefaults(); + + assert.equal(DEFAULT_TOP_SITES.length, 1); + assert.equal(DEFAULT_TOP_SITES[0].label, "test"); + }); + + it("should not filter non-sponsored tiles from RemoteSettings", async () => { + sandbox.stub(feed, "_getRemoteConfig").resolves([ + { url: "https://foo.com", title: "foo", sponsored_position: 1 }, + { url: "https://bar.com", title: "bar", sponsored_position: 2 }, + { url: "https://foo.com", title: "foo" }, + ]); + + await feed._readDefaults(); + + assert.equal(DEFAULT_TOP_SITES.length, 1); + assert.equal(DEFAULT_TOP_SITES[0].label, "foo"); + }); + + it("should take the image from Contile if it's a hi-res one", async () => { + fakeNimbusFeatures.newtab.getVariable.returns(true); + sandbox.stub(feed, "_getRemoteConfig").resolves([]); + + sandbox.stub(feed._contile, "sites").get(() => [ + { + url: "https://test.com", + image_url: "https://images.test.com/test-com.png", + image_size: 192, + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://test1.com", + image_url: "https://images.test1.com/test1-com.png", + image_size: 32, + click_url: "https://www.test1-click.com", + impression_url: "https://www.test1-impression.com", + name: "test1", + }, + ]); + + await feed._readDefaults(); + + const [site1, site2] = DEFAULT_TOP_SITES; + assert.propertyVal( + site1, + "favicon", + "https://images.test.com/test-com.png" + ); + assert.propertyVal(site1, "faviconSize", 192); + + // Should not be taken as it's not hi-res + assert.isUndefined(site2.favicon); + assert.isUndefined(site2.faviconSize); + }); + }); + + describe("#_nimbusChangeListener", () => { + it("should refresh on Nimbus feature updates reasons", () => { + sandbox.spy(feed._contile, "refresh"); + feed._nimbusChangeListener(null, "experiment-updated"); + + assert.calledOnce(feed._contile.refresh); + }); + + it("should not refresh on Nimbus feature loaded reasons", () => { + sandbox.spy(feed._contile, "refresh"); + feed._nimbusChangeListener(null, "feature-experiment-loaded"); + feed._nimbusChangeListener(null, "feature-rollout-loaded"); + + assert.notCalled(feed._contile.refresh); + }); + }); + + describe("#_maybeCapSponsoredLinks", () => { + let sponsoredLinks; + + beforeEach(() => { + sponsoredLinks = [ + { + url: "https://www.test.com", + name: "test", + sponsored_position: 1, + }, + { + url: "https://www.test1.com", + name: "test1", + sponsored_position: 2, + }, + { + url: "https://www.test2.com", + name: "test2", + sponsored_position: 3, + }, + ]; + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should fall back to the default if the Nimbus variable is unspecified", () => { + feed._maybeCapSponsoredLinks(sponsoredLinks); + + assert.equal(sponsoredLinks.length, 2); + }); + it("should cap the links if specified by the Nimbus variable", () => { + fakeNimbusFeatures.pocketNewtab.getVariable.returns(1); + + feed._maybeCapSponsoredLinks(sponsoredLinks); + + assert.equal(sponsoredLinks.length, 1); + }); + it("should leave all the links if the Nimbus variable is equal to what we have", () => { + fakeNimbusFeatures.pocketNewtab.getVariable.returns(3); + + feed._maybeCapSponsoredLinks(sponsoredLinks); + + assert.equal(sponsoredLinks.length, 3); + }); + it("should ignore caps if they are more than what we have", () => { + fakeNimbusFeatures.pocketNewtab.getVariable.returns(10); + + feed._maybeCapSponsoredLinks(sponsoredLinks); + + assert.equal(sponsoredLinks.length, 3); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js new file mode 100644 index 0000000000..f6560d7ab2 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/TopStoriesFeed.test.js @@ -0,0 +1,1903 @@ +import { FAKE_GLOBAL_PREFS, FakePrefs, GlobalOverrider } from "test/unit/utils"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import injector from "inject!lib/TopStoriesFeed.jsm"; + +describe("Top Stories Feed", () => { + let TopStoriesFeed; + let STORIES_UPDATE_TIME; + let TOPICS_UPDATE_TIME; + let SECTION_ID; + let SPOC_IMPRESSION_TRACKING_PREF; + let REC_IMPRESSION_TRACKING_PREF; + let DEFAULT_RECS_EXPIRE_TIME; + let instance; + let clock; + let globals; + let sectionsManagerStub; + let shortURLStub; + + const FAKE_OPTIONS = { + stories_endpoint: "https://somedomain.org/stories?key=$apiKey", + stories_referrer: "https://somedomain.org/referrer", + topics_endpoint: "https://somedomain.org/topics?key=$apiKey", + survey_link: "https://www.surveymonkey.com/r/newtabffx", + api_key_pref: "apiKeyPref", + provider_name: "test-provider", + provider_icon: "provider-icon", + provider_description: "provider_desc", + }; + + beforeEach(() => { + FAKE_GLOBAL_PREFS.set("apiKeyPref", "test-api-key"); + FAKE_GLOBAL_PREFS.set( + "pocketCta", + JSON.stringify({ + cta_button: "", + cta_text: "", + cta_url: "", + use_cta: false, + }) + ); + + globals = new GlobalOverrider(); + globals.set("PlacesUtils", { history: {} }); + globals.set("pktApi", { isUserLoggedIn() {} }); + clock = sinon.useFakeTimers(); + shortURLStub = sinon.stub().callsFake(site => site.url); + sectionsManagerStub = { + onceInitialized: sinon.stub().callsFake(callback => callback()), + enableSection: sinon.spy(), + disableSection: sinon.spy(), + updateSection: sinon.spy(), + sections: new Map([["topstories", { options: FAKE_OPTIONS }]]), + }; + + ({ + TopStoriesFeed, + STORIES_UPDATE_TIME, + TOPICS_UPDATE_TIME, + SECTION_ID, + SPOC_IMPRESSION_TRACKING_PREF, + REC_IMPRESSION_TRACKING_PREF, + DEFAULT_RECS_EXPIRE_TIME, + } = injector({ + "lib/ActivityStreamPrefs.jsm": { Prefs: FakePrefs }, + "lib/ShortURL.jsm": { shortURL: shortURLStub }, + "lib/SectionsManager.jsm": { SectionsManager: sectionsManagerStub }, + })); + + instance = new TopStoriesFeed(); + instance.store = { + getState() { + return { + Prefs: { + values: { + showSponsored: true, + "feeds.section.topstories": true, + }, + }, + }; + }, + dispatch: sinon.spy(), + }; + instance.storiesLastUpdated = 0; + instance.topicsLastUpdated = 0; + }); + afterEach(() => { + globals.restore(); + clock.restore(); + }); + + describe("#lazyloading TopStories", () => { + beforeEach(() => { + instance.discoveryStreamEnabled = true; + }); + it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is true", () => { + instance.discoveryStreamEnabled = false; + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: true }), + "feeds.section.topstories": true, + }, + }, + }); + instance.onAction({ type: at.INIT, data: {} }); + + assert.calledOnce(sectionsManagerStub.onceInitialized); + }); + it("should bind parseOptions to SectionsManager.onceInitialized when discovery stream is false", () => { + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: false }), + "feeds.section.topstories": true, + }, + }, + }); + instance.onAction({ type: at.INIT, data: {} }); + assert.calledOnce(sectionsManagerStub.onceInitialized); + }); + it("Should initialize properties once while lazy loading if not initialized earlier", () => { + instance.discoveryStreamEnabled = false; + instance.propertiesInitialized = false; + sinon.stub(instance, "initializeProperties"); + instance.lazyLoadTopStories(); + assert.calledOnce(instance.initializeProperties); + }); + it("should not re-initialize properties", () => { + // For discovery stream experience disabled TopStoriesFeed properties + // are initialized in constructor and should not be called again while lazy loading topstories + sinon.stub(instance, "initializeProperties"); + instance.discoveryStreamEnabled = false; + instance.propertiesInitialized = true; + instance.lazyLoadTopStories(); + assert.notCalled(instance.initializeProperties); + }); + it("should have early exit onInit when discovery is true", async () => { + sinon.stub(instance, "doContentUpdate"); + await instance.onInit(); + assert.notCalled(instance.doContentUpdate); + assert.isUndefined(instance.storiesLoaded); + }); + it("should complete onInit when discovery is false", async () => { + instance.discoveryStreamEnabled = false; + sinon.stub(instance, "doContentUpdate"); + await instance.onInit(); + assert.calledOnce(instance.doContentUpdate); + assert.isTrue(instance.storiesLoaded); + }); + it("should handle limited actions when discoverystream is enabled", async () => { + sinon.spy(instance, "handleDisabled"); + sinon.stub(instance, "getPocketState"); + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: true }), + "discoverystream.enabled": true, + "feeds.section.topstories": true, + }, + }, + }); + + instance.onAction({ type: at.INIT, data: {} }); + + assert.calledOnce(instance.handleDisabled); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.notCalled(instance.getPocketState); + }); + it("should handle NEW_TAB_REHYDRATED when discoverystream is disabled", async () => { + instance.discoveryStreamEnabled = false; + sinon.spy(instance, "handleDisabled"); + sinon.stub(instance, "getPocketState"); + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: false }), + "feeds.section.topstories": true, + }, + }, + }); + instance.onAction({ type: at.INIT, data: {} }); + assert.notCalled(instance.handleDisabled); + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.getPocketState); + }); + it("should handle UNINIT when discoverystream is enabled", async () => { + sinon.stub(instance, "uninit"); + instance.onAction({ type: at.UNINIT }); + assert.calledOnce(instance.uninit); + }); + it("should fire init on PREF_CHANGED", () => { + sinon.stub(instance, "onInit"); + instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.config", value: {} }, + }); + assert.calledOnce(instance.onInit); + }); + it("should fire init on DISCOVERY_STREAM_PREF_ENABLED", () => { + sinon.stub(instance, "onInit"); + instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.enabled", value: true }, + }); + assert.calledOnce(instance.onInit); + }); + it("should not fire init on PREF_CHANGED if stories are loaded", () => { + sinon.stub(instance, "onInit"); + sinon.spy(instance, "lazyLoadTopStories"); + instance.storiesLoaded = true; + instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.config", value: {} }, + }); + assert.calledOnce(instance.lazyLoadTopStories); + assert.notCalled(instance.onInit); + }); + it("should fire init on PREF_CHANGED when discoverystream is disabled", () => { + instance.discoveryStreamEnabled = false; + sinon.stub(instance, "onInit"); + instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.config", value: {} }, + }); + assert.calledOnce(instance.onInit); + }); + it("should not fire init on PREF_CHANGED when discoverystream is disabled and stories are loaded", () => { + instance.discoveryStreamEnabled = false; + sinon.stub(instance, "onInit"); + sinon.spy(instance, "lazyLoadTopStories"); + instance.storiesLoaded = true; + instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "discoverystream.config", value: {} }, + }); + assert.calledOnce(instance.lazyLoadTopStories); + assert.notCalled(instance.onInit); + }); + it("should not init props if ds pref is true", () => { + sinon.stub(instance, "initializeProperties"); + instance.propertiesInitialized = false; + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: false }), + "discoverystream.enabled": true, + "feeds.section.topstories": true, + }, + }, + }); + instance.lazyLoadTopStories({ + dsPref: JSON.stringify({ enabled: true }), + }); + assert.notCalled(instance.initializeProperties); + }); + it("should fire init if user pref is true", () => { + sinon.stub(instance, "onInit"); + instance.store.getState = () => ({ + Prefs: { + values: { + "discoverystream.config": JSON.stringify({ enabled: false }), + "discoverystream.enabled": false, + "feeds.section.topstories": false, + }, + }, + }); + instance.lazyLoadTopStories({ userPref: true }); + assert.calledOnce(instance.onInit); + }); + it("should fire uninit if topstories update to false", () => { + sinon.stub(instance, "uninit"); + instance.discoveryStreamEnabled = false; + instance.onAction({ + type: at.PREF_CHANGED, + data: { + value: false, + name: "feeds.section.topstories", + }, + }); + assert.calledOnce(instance.uninit); + instance.discoveryStreamEnabled = true; + instance.onAction({ + type: at.PREF_CHANGED, + data: { + value: false, + name: "feeds.section.topstories", + }, + }); + assert.calledTwice(instance.uninit); + }); + it("should fire lazyLoadTopstories if topstories update to true", () => { + sinon.stub(instance, "lazyLoadTopStories"); + instance.discoveryStreamEnabled = false; + instance.onAction({ + type: at.PREF_CHANGED, + data: { + value: true, + name: "feeds.section.topstories", + }, + }); + assert.calledOnce(instance.lazyLoadTopStories); + instance.discoveryStreamEnabled = true; + instance.onAction({ + type: at.PREF_CHANGED, + data: { + value: true, + name: "feeds.section.topstories", + }, + }); + assert.calledTwice(instance.lazyLoadTopStories); + }); + }); + + describe("#init", () => { + it("should create a TopStoriesFeed", () => { + assert.instanceOf(instance, TopStoriesFeed); + }); + it("should bind parseOptions to SectionsManager.onceInitialized", () => { + instance.onAction({ type: at.INIT, data: {} }); + assert.calledOnce(sectionsManagerStub.onceInitialized); + }); + it("should initialize endpoints based on options", async () => { + await instance.onInit(); + assert.equal( + "https://somedomain.org/stories?key=test-api-key", + instance.stories_endpoint + ); + assert.equal( + "https://somedomain.org/referrer", + instance.stories_referrer + ); + assert.equal( + "https://somedomain.org/topics?key=test-api-key", + instance.topics_endpoint + ); + }); + it("should enable its section", () => { + instance.onAction({ type: at.INIT, data: {} }); + assert.calledOnce(sectionsManagerStub.enableSection); + assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID); + }); + it("init should fire onInit", () => { + instance.onInit = sinon.spy(); + instance.onAction({ type: at.INIT, data: {} }); + assert.calledOnce(instance.onInit); + }); + it("should fetch stories on init", async () => { + instance.fetchStories = sinon.spy(); + await instance.onInit(); + assert.calledOnce(instance.fetchStories); + }); + it("should fetch topics on init", async () => { + instance.fetchTopics = sinon.spy(); + await instance.onInit(); + assert.calledOnce(instance.fetchTopics); + }); + it("should not fetch if endpoint not configured", () => { + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + sectionsManagerStub.sections.set("topstories", { options: {} }); + instance.init(); + assert.notCalled(fetchStub); + }); + it("should report error for invalid configuration", () => { + globals.sandbox.spy(global.console, "error"); + sectionsManagerStub.sections.set("topstories", { + options: { + api_key_pref: "invalid", + stories_endpoint: "https://invalid.com/?apiKey=$apiKey", + }, + }); + instance.init(); + + assert.calledWith( + console.error, + "Problem initializing top stories feed: An API key was specified but none configured: https://invalid.com/?apiKey=$apiKey" + ); + }); + it("should report error for missing api key", () => { + globals.sandbox.spy(global.console, "error"); + sectionsManagerStub.sections.set("topstories", { + options: { + stories_endpoint: "https://somedomain.org/stories?key=$apiKey", + topics_endpoint: "https://somedomain.org/topics?key=$apiKey", + }, + }); + instance.init(); + + assert.called(console.error); + }); + it("should load data from cache on init", async () => { + instance.loadCachedData = sinon.spy(); + await instance.onInit(); + assert.calledOnce(instance.loadCachedData); + }); + }); + describe("#uninit", () => { + it("should disable its section", () => { + instance.onAction({ type: at.UNINIT }); + assert.calledOnce(sectionsManagerStub.disableSection); + assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID); + }); + it("should unload stories on uninit", async () => { + sinon.stub(instance.cache, "set").returns(Promise.resolve()); + await instance.clearCache(); + assert.calledWith(instance.cache.set.firstCall, "stories", {}); + assert.calledWith(instance.cache.set.secondCall, "topics", {}); + assert.calledWith(instance.cache.set.thirdCall, "spocs", {}); + }); + }); + describe("#cache", () => { + it("should clear all cache items when calling clearCache", () => { + sinon.stub(instance.cache, "set").returns(Promise.resolve()); + instance.storiesLoaded = true; + instance.uninit(); + assert.equal(instance.storiesLoaded, false); + }); + it("should set spocs cache on fetch", async () => { + const response = { + recommendations: [{ id: "1" }, { id: "2" }], + settings: {}, + spocs: [{ id: "spoc1" }], + }; + + instance.show_spocs = true; + instance.stories_endpoint = "stories-endpoint"; + + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + sinon.spy(instance.cache, "set"); + + await instance.fetchStories(); + + assert.calledOnce(instance.cache.set); + const { args } = instance.cache.set.firstCall; + assert.equal(args[0], "stories"); + assert.equal(args[1].spocs[0].id, "spoc1"); + }); + it("should get spocs on cache load", async () => { + instance.cache.get = () => ({ + stories: { + recommendations: [{ id: "1" }, { id: "2" }], + spocs: [{ id: "spoc1" }], + }, + }); + instance.storiesLastUpdated = 0; + globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } }); + + await instance.loadCachedData(); + assert.equal(instance.spocs[0].guid, "spoc1"); + }); + }); + describe("#fetch", () => { + it("should fetch stories, send event and cache results", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { + stories_endpoint: "stories-endpoint", + stories_referrer: "referrer", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + recommendations: [ + { + id: "1", + title: "title", + excerpt: "description", + image_src: "image-url", + url: "rec-url", + published_timestamp: "123", + context: "trending", + icon: "icon", + }, + ], + }; + const stories = [ + { + guid: "1", + type: "now", + title: "title", + context: "trending", + icon: "icon", + description: "description", + image: "image-url", + referrer: "referrer", + url: "rec-url", + hostname: "rec-url", + score: 1, + spoc_meta: {}, + }, + ]; + + instance.cache.set = sinon.spy(); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + assert.calledOnce(fetchStub); + assert.calledOnce(shortURLStub); + assert.calledWithExactly(fetchStub, instance.stories_endpoint, { + credentials: "omit", + }); + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, { + rows: stories, + }); + assert.calledOnce(instance.cache.set); + assert.calledWith( + instance.cache.set, + "stories", + Object.assign({}, response, { _timestamp: 0 }) + ); + }); + it("should use domain as hostname, if present", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { + stories_endpoint: "stories-endpoint", + stories_referrer: "referrer", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + recommendations: [ + { + id: "1", + title: "title", + excerpt: "description", + image_src: "image-url", + url: "rec-url", + domain: "domain", + published_timestamp: "123", + context: "trending", + icon: "icon", + }, + ], + }; + const stories = [ + { + guid: "1", + type: "now", + title: "title", + context: "trending", + icon: "icon", + description: "description", + image: "image-url", + referrer: "referrer", + url: "rec-url", + hostname: "domain", + score: 1, + spoc_meta: {}, + }, + ]; + + instance.cache.set = sinon.spy(); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + assert.calledOnce(fetchStub); + assert.notCalled(shortURLStub); + assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, { + rows: stories, + }); + }); + it("should call SectionsManager.updateSection", () => { + instance.dispatchUpdateEvent(123, {}); + assert.calledOnce(sectionsManagerStub.updateSection); + }); + it("should report error for unexpected stories response", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { stories_endpoint: "stories-endpoint" }, + }); + globals.set("fetch", fetchStub); + globals.sandbox.spy(global.console, "error"); + + fetchStub.resolves({ ok: false, status: 400 }); + await instance.onInit(); + + assert.calledOnce(fetchStub); + assert.calledWithExactly(fetchStub, instance.stories_endpoint, { + credentials: "omit", + }); + assert.equal(instance.storiesLastUpdated, 0); + assert.called(console.error); + }); + it("should exclude blocked (dismissed) URLs", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { stories_endpoint: "stories-endpoint" }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: site => site.url === "blocked" }, + }); + + const response = { + recommendations: [{ url: "blocked" }, { url: "not_blocked" }], + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + // Issue! + // Should actually be fixed when cache is fixed. + assert.calledOnce(sectionsManagerStub.updateSection); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows.length, + 1 + ); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url, + "not_blocked" + ); + }); + it("should mark stories as new", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { stories_endpoint: "stories-endpoint" }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + clock.restore(); + const response = { + recommendations: [ + { published_timestamp: Date.now() / 1000 }, + { published_timestamp: "0" }, + { + published_timestamp: (Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000, + }, + ], + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + + await instance.onInit(); + assert.calledOnce(sectionsManagerStub.updateSection); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows.length, + 3 + ); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type, + "now" + ); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type, + "trending" + ); + assert.equal( + sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type, + "trending" + ); + }); + it("should fetch topics, send event and cache results", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { topics_endpoint: "topics-endpoint" }, + }); + globals.set("fetch", fetchStub); + + const response = { + topics: [ + { name: "topic1", url: "url-topic1" }, + { name: "topic2", url: "url-topic2" }, + ], + }; + const topics = [ + { + name: "topic1", + url: "url-topic1", + }, + { + name: "topic2", + url: "url-topic2", + }, + ]; + + instance.cache.set = sinon.spy(); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + assert.calledOnce(fetchStub); + assert.calledWithExactly(fetchStub, instance.topics_endpoint, { + credentials: "omit", + }); + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, { + topics, + }); + assert.calledOnce(instance.cache.set); + assert.calledWith( + instance.cache.set, + "topics", + Object.assign({}, response, { _timestamp: 0 }) + ); + }); + it("should report error for unexpected topics response", async () => { + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.sandbox.spy(global.console, "error"); + + instance.topics_endpoint = "topics-endpoint"; + fetchStub.resolves({ ok: false, status: 400 }); + await instance.fetchTopics(); + + assert.calledOnce(fetchStub); + assert.calledWithExactly(fetchStub, instance.topics_endpoint, { + credentials: "omit", + }); + assert.notCalled(instance.store.dispatch); + assert.called(console.error); + }); + }); + describe("#personalization", () => { + it("should sort stories", async () => { + const response = { + recommendations: [{ id: "1" }, { id: "2" }], + settings: {}, + }; + + instance.compareScore = sinon.spy(); + instance.stories_endpoint = "stories-endpoint"; + + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + + await instance.fetchStories(); + assert.calledOnce(instance.compareScore); + }); + it("should sort items based on relevance score", () => { + let items = [{ score: 0.1 }, { score: 0.2 }]; + items = items.sort(instance.compareScore); + assert.deepEqual(items, [{ score: 0.2 }, { score: 0.1 }]); + }); + it("should rotate items", () => { + let items = [ + { guid: "g1" }, + { guid: "g2" }, + { guid: "g3" }, + { guid: "g4" }, + { guid: "g5" }, + { guid: "g6" }, + ]; + + // No impressions should leave items unchanged + let rotated = instance.rotate(items); + assert.deepEqual(items, rotated); + + // Recent impression should leave items unchanged + instance._prefs.get = pref => + pref === REC_IMPRESSION_TRACKING_PREF && + JSON.stringify({ g1: 1, g2: 1, g3: 1 }); + rotated = instance.rotate(items); + assert.deepEqual(items, rotated); + + // Impression older than expiration time should rotate items + clock.tick(DEFAULT_RECS_EXPIRE_TIME + 1); + rotated = instance.rotate(items); + assert.deepEqual( + [ + { guid: "g4" }, + { guid: "g5" }, + { guid: "g6" }, + { guid: "g1" }, + { guid: "g2" }, + { guid: "g3" }, + ], + rotated + ); + + instance._prefs.get = pref => + pref === REC_IMPRESSION_TRACKING_PREF && + JSON.stringify({ + g1: 1, + g2: 1, + g3: 1, + g4: DEFAULT_RECS_EXPIRE_TIME + 1, + }); + clock.tick(DEFAULT_RECS_EXPIRE_TIME); + rotated = instance.rotate(items); + assert.deepEqual( + [ + { guid: "g5" }, + { guid: "g6" }, + { guid: "g1" }, + { guid: "g2" }, + { guid: "g3" }, + { guid: "g4" }, + ], + rotated + ); + }); + it("should record top story impressions", async () => { + instance._prefs = { get: pref => undefined, set: sinon.spy() }; + + clock.tick(1); + let expectedPrefValue = JSON.stringify({ 1: 1, 2: 1, 3: 1 }); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }); + assert.calledWith( + instance._prefs.set.firstCall, + REC_IMPRESSION_TRACKING_PREF, + expectedPrefValue + ); + + // Only need to record first impression, so impression pref shouldn't change + instance._prefs.get = pref => expectedPrefValue; + clock.tick(1); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }); + assert.calledOnce(instance._prefs.set); + + // New first impressions should be added + clock.tick(1); + let expectedPrefValueTwo = JSON.stringify({ + 1: 1, + 2: 1, + 3: 1, + 4: 3, + 5: 3, + 6: 3, + }); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 4 }, { id: 5 }, { id: 6 }], + }, + }); + assert.calledWith( + instance._prefs.set.secondCall, + REC_IMPRESSION_TRACKING_PREF, + expectedPrefValueTwo + ); + }); + it("should not record top story impressions for non-view impressions", async () => { + instance._prefs = { get: pref => undefined, set: sinon.spy() }; + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + }); + it("should clean up top story impressions", async () => { + instance._prefs = { + get: pref => JSON.stringify({ 1: 1, 2: 1, 3: 1 }), + set: sinon.spy(), + }; + + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + instance.stories_endpoint = "stories-endpoint"; + const response = { recommendations: [{ id: 3 }, { id: 4 }, { id: 5 }] }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.fetchStories(); + + // Should remove impressions for rec 1 and 2 as no longer in the feed + assert.calledWith( + instance._prefs.set.firstCall, + REC_IMPRESSION_TRACKING_PREF, + JSON.stringify({ 3: 1 }) + ); + }); + it("should not change provider with badly formed JSON", async () => { + sinon.stub(instance, "uninit"); + sinon.stub(instance, "init"); + sinon.stub(instance, "clearCache").returns(Promise.resolve()); + await instance.onAction({ + type: at.PREF_CHANGED, + data: { + name: "feeds.section.topstories.options", + value: "{version: 2}", + }, + }); + assert.notCalled(instance.uninit); + assert.notCalled(instance.init); + assert.notCalled(instance.clearCache); + }); + }); + describe("#spocs", async () => { + it("should not display expired or untimestamped spocs", async () => { + clock.tick(441792000000); // 01/01/1984 + + instance.spocsPerNewTabs = 1; + instance.show_spocs = true; + instance.isBelowFrequencyCap = () => true; + + // NOTE: `expiration_timestamp` is seconds since UNIX epoch + instance.spocs = [ + // No timestamp stays visible + { + id: "spoc1", + }, + // Expired spoc gets filtered out + { + id: "spoc2", + expiration_timestamp: 1, + }, + // Far future expiration spoc stays visible + { + id: "spoc3", + expiration_timestamp: 32503708800, // 01/01/3000 + }, + ]; + + sinon.spy(instance, "filterSpocs"); + + instance.filterSpocs(); + + assert.equal(instance.filterSpocs.firstCall.returnValue.length, 2); + assert.equal(instance.filterSpocs.firstCall.returnValue[0].id, "spoc1"); + assert.equal(instance.filterSpocs.firstCall.returnValue[1].id, "spoc3"); + }); + it("should insert spoc with provided probability", async () => { + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }], + // Include spocs with a expiration in the very distant future + spocs: [ + { id: "spoc1", expiration_timestamp: 9999999999999 }, + { id: "spoc2", expiration_timestamp: 9999999999999 }, + ], + }; + + instance.show_spocs = true; + instance.stories_endpoint = "stories-endpoint"; + instance.storiesLoaded = true; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.fetchStories(); + + instance.store.getState = () => ({ + Sections: [{ id: "topstories", rows: response.recommendations }], + Prefs: { values: { showSponsored: true } }, + }); + + globals.set("Math", { + random: () => 0.4, + min: Math.min, + }); + instance.dispatchSpocDone = () => {}; + instance.getPocketState = () => {}; + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.store.dispatch); + let [action] = instance.store.dispatch.firstCall.args; + + assert.equal(at.SECTION_UPDATE, action.type); + assert.equal(true, action.meta.skipMain); + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc1"); + // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh + assert.equal(action.data.rows[2].pinned, true); + + // Second new tab shouldn't trigger a section update event (spocsPerNewTab === 0.5) + globals.set("Math", { + random: () => 0.6, + min: Math.min, + }); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.store.dispatch); + + globals.set("Math", { + random: () => 0.3, + min: Math.min, + }); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledTwice(instance.store.dispatch); + [action] = instance.store.dispatch.secondCall.args; + assert.equal(at.SECTION_UPDATE, action.type); + assert.equal(true, action.meta.skipMain); + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc1"); + // Make sure spoc is marked as pinned so it doesn't get removed when preloaded tabs refresh + assert.equal(action.data.rows[2].pinned, true); + }); + it("should delay inserting spoc if stories haven't been fetched", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + globals.set("Math", { + random: () => 0.4, + min: Math.min, + floor: Math.floor, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }], + // Include one spoc with a expiration in the very distant future + spocs: [ + { id: "spoc1", expiration_timestamp: 9999999999999 }, + { id: "spoc2" }, + ], + }; + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.notCalled(instance.store.dispatch); + assert.equal(instance.contentUpdateQueue.length, 1); + + instance.spocsPerNewTabs = 0.5; + instance.store.getState = () => ({ + Sections: [{ id: "topstories", rows: response.recommendations }], + Prefs: { values: { showSponsored: true } }, + }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + + await instance.onInit(); + assert.equal(instance.contentUpdateQueue.length, 0); + assert.calledOnce(instance.store.dispatch); + let [action] = instance.store.dispatch.firstCall.args; + assert.equal(action.type, at.SECTION_UPDATE); + }); + it("should not insert spoc if preffed off", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: false, + stories_endpoint: "stories-endpoint", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + spocs: [{ id: "spoc1" }, { id: "spoc2" }], + }; + sinon.spy(instance, "maybeAddSpoc"); + sinon.spy(instance, "shouldShowSpocs"); + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.maybeAddSpoc); + assert.calledOnce(instance.shouldShowSpocs); + assert.notCalled(instance.store.dispatch); + }); + it("should call dispatchSpocDone when calling maybeAddSpoc", async () => { + instance.dispatchSpocDone = sinon.spy(); + instance.storiesLoaded = true; + await instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.dispatchSpocDone); + assert.calledWith(instance.dispatchSpocDone, {}); + }); + it("should fire POCKET_WAITING_FOR_SPOC action with false", () => { + instance.dispatchSpocDone({}); + assert.calledOnce(instance.store.dispatch); + const [action] = instance.store.dispatch.firstCall.args; + assert.equal(action.type, "POCKET_WAITING_FOR_SPOC"); + assert.equal(action.data, false); + }); + it("should not insert spoc if user opted out", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + spocs: [{ id: "spoc1" }, { id: "spoc2" }], + }; + + instance.store.getState = () => ({ + Sections: [{ id: "topstories", rows: response.recommendations }], + Prefs: { values: { showSponsored: false } }, + }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.notCalled(instance.store.dispatch); + }); + it("should not fail if there is no spoc", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + globals.set("Math", { + random: () => 0.4, + min: Math.min, + }); + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + recommendations: [{ id: "rec1" }, { id: "rec2" }, { id: "rec3" }], + }; + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.notCalled(instance.store.dispatch); + }); + it("should record spoc/campaign impressions for frequency capping", async () => { + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + globals.set("Math", { + random: () => 0.4, + min: Math.min, + floor: Math.floor, + }); + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + spocs: [ + { id: 1, campaign_id: 5 }, + { id: 4, campaign_id: 6 }, + ], + }; + + instance._prefs = { get: pref => undefined, set: sinon.spy() }; + instance.show_spocs = true; + instance.stories_endpoint = "stories-endpoint"; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.fetchStories(); + + let expectedPrefValue = JSON.stringify({ 5: [0] }); + let expectedPrefValueCallTwo = JSON.stringify({ 2: 0, 3: 0 }); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 3 }, { id: 2 }, { id: 1 }], + }, + }); + assert.calledWith( + instance._prefs.set.firstCall, + SPOC_IMPRESSION_TRACKING_PREF, + expectedPrefValue + ); + assert.calledWith( + instance._prefs.set.secondCall, + REC_IMPRESSION_TRACKING_PREF, + expectedPrefValueCallTwo + ); + + clock.tick(1); + instance._prefs.get = pref => expectedPrefValue; + let expectedPrefValueCallThree = JSON.stringify({ 5: [0, 1] }); + let expectedPrefValueCallFour = JSON.stringify({ 2: 1, 3: 1, 5: [0] }); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 3 }, { id: 2 }, { id: 1 }], + }, + }); + assert.calledWith( + instance._prefs.set.thirdCall, + SPOC_IMPRESSION_TRACKING_PREF, + expectedPrefValueCallThree + ); + assert.calledWith( + instance._prefs.set.getCall(3), + REC_IMPRESSION_TRACKING_PREF, + expectedPrefValueCallFour + ); + + clock.tick(1); + instance._prefs.get = pref => expectedPrefValueCallThree; + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 3 }, { id: 2 }, { id: 4 }], + }, + }); + assert.calledWith( + instance._prefs.set.getCall(4), + SPOC_IMPRESSION_TRACKING_PREF, + JSON.stringify({ 5: [0, 1], 6: [2] }) + ); + assert.calledWith( + instance._prefs.set.getCall(5), + REC_IMPRESSION_TRACKING_PREF, + JSON.stringify({ 2: 2, 3: 2, 5: [0, 1] }) + ); + }); + it("should not record spoc/campaign impressions for non-view impressions", async () => { + let fetchStub = globals.sandbox.stub(); + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + spocs: [ + { id: 1, campaign_id: 5 }, + { id: 4, campaign_id: 6 }, + ], + }; + + instance._prefs = { get: pref => undefined, set: sinon.spy() }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", click: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", block: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { source: "TOP_STORIES", pocket: 0, tiles: [{ id: 1 }] }, + }); + assert.notCalled(instance._prefs.set); + }); + it("should clean up spoc/campaign impressions", async () => { + let fetchStub = globals.sandbox.stub(); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + instance._prefs = { get: pref => undefined, set: sinon.spy() }; + instance.show_spocs = true; + instance.stories_endpoint = "stories-endpoint"; + + const response = { + settings: { spocsPerNewTabs: 0.5 }, + spocs: [ + { id: 1, campaign_id: 5 }, + { id: 4, campaign_id: 6 }, + ], + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.fetchStories(); + + // simulate impressions for campaign 5 and 6 + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 3 }, { id: 2 }, { id: 1 }], + }, + }); + instance._prefs.get = pref => + pref === SPOC_IMPRESSION_TRACKING_PREF && JSON.stringify({ 5: [0] }); + instance.onAction({ + type: at.TELEMETRY_IMPRESSION_STATS, + data: { + source: "TOP_STORIES", + tiles: [{ id: 3 }, { id: 2 }, { id: 4 }], + }, + }); + + let expectedPrefValue = JSON.stringify({ 5: [0], 6: [0] }); + assert.calledWith( + instance._prefs.set.thirdCall, + SPOC_IMPRESSION_TRACKING_PREF, + expectedPrefValue + ); + instance._prefs.get = pref => + pref === SPOC_IMPRESSION_TRACKING_PREF && expectedPrefValue; + + // remove campaign 5 from response + const updatedResponse = { + settings: { spocsPerNewTabs: 1 }, + spocs: [{ id: 4, campaign_id: 6 }], + }; + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(updatedResponse), + }); + await instance.fetchStories(); + + // should remove campaign 5 from pref as no longer active + assert.calledWith( + instance._prefs.set.getCall(4), + SPOC_IMPRESSION_TRACKING_PREF, + JSON.stringify({ 6: [0] }) + ); + }); + it("should maintain frequency caps when inserting spocs", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + const response = { + settings: { spocsPerNewTabs: 1 }, + recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }], + spocs: [ + // Set spoc `expiration_timestamp`s in the very distant future to ensure they show up + { + id: "spoc1", + campaign_id: 1, + caps: { lifetime: 3, campaign: { count: 2, period: 3600 } }, + expiration_timestamp: 999999999999, + }, + { + id: "spoc2", + campaign_id: 2, + caps: { lifetime: 1 }, + expiration_timestamp: 999999999999, + }, + ], + }; + + instance.store.getState = () => ({ + Sections: [{ id: "topstories", rows: response.recommendations }], + Prefs: { values: { showSponsored: true } }, + }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + instance.spocsPerNewTabs = 1; + + clock.tick(); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + let [action] = instance.store.dispatch.firstCall.args; + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc1"); + instance._prefs.get = pref => JSON.stringify({ 1: [1] }); + + clock.tick(); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + [action] = instance.store.dispatch.secondCall.args; + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc1"); + instance._prefs.get = pref => JSON.stringify({ 1: [1, 2] }); + + // campaign 1 period frequency cap now reached (spoc 2 should be shown) + clock.tick(); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + [action] = instance.store.dispatch.thirdCall.args; + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc2"); + instance._prefs.get = pref => JSON.stringify({ 1: [1, 2], 2: [3] }); + + // new campaign 1 period starting (spoc 1 sohuld be shown again) + clock.tick(2 * 60 * 60 * 1000); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + [action] = instance.store.dispatch.lastCall.args; + assert.equal(action.data.rows[0].guid, "rec1"); + assert.equal(action.data.rows[1].guid, "rec2"); + assert.equal(action.data.rows[2].guid, "spoc1"); + instance._prefs.get = pref => + JSON.stringify({ 1: [1, 2, 7200003], 2: [3] }); + + // campaign 1 lifetime cap now reached (no spoc should be sent) + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.callCount(instance.store.dispatch, 4); + }); + it("should maintain client-side MAX_LIFETIME_CAP", async () => { + let fetchStub = globals.sandbox.stub(); + instance.dispatchSpocDone = () => {}; + sectionsManagerStub.sections.set("topstories", { + options: { + show_spocs: true, + stories_endpoint: "stories-endpoint", + }, + }); + globals.set("fetch", fetchStub); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + instance.getPocketState = () => {}; + instance.dispatchPocketCta = () => {}; + + const response = { + settings: { spocsPerNewTabs: 1 }, + recommendations: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }], + spocs: [{ id: "spoc1", campaign_id: 1, caps: { lifetime: 501 } }], + }; + + instance.store.getState = () => ({ + Sections: [{ id: "topstories", rows: response.recommendations }], + Prefs: { values: { showSponsored: true } }, + }); + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(response), + }); + await instance.onInit(); + + instance._prefs.get = pref => + JSON.stringify({ 1: [...Array(500).keys()] }); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.notCalled(instance.store.dispatch); + }); + }); + describe("#update", () => { + it("should fetch stories after update interval", async () => { + await instance.onInit(); + sinon.spy(instance, "fetchStories"); + await instance.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(instance.fetchStories); + + clock.tick(STORIES_UPDATE_TIME); + await instance.onAction({ type: at.SYSTEM_TICK }); + assert.calledOnce(instance.fetchStories); + }); + it("should fetch topics after update interval", async () => { + await instance.onInit(); + sinon.spy(instance, "fetchTopics"); + await instance.onAction({ type: at.SYSTEM_TICK }); + assert.notCalled(instance.fetchTopics); + + clock.tick(TOPICS_UPDATE_TIME); + await instance.onAction({ type: at.SYSTEM_TICK }); + assert.calledOnce(instance.fetchTopics); + }); + it("should return updated stories and topics on system tick", async () => { + await instance.onInit(); + sinon.spy(instance, "dispatchUpdateEvent"); + const stories = [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }]; + const topics = [ + { name: "topic1", url: "url-topic1" }, + { name: "topic2", url: "url-topic2" }, + ]; + clock.tick(TOPICS_UPDATE_TIME); + globals.sandbox.stub(instance, "fetchStories").resolves(stories); + globals.sandbox.stub(instance, "fetchTopics").resolves(topics); + + await instance.onAction({ type: at.SYSTEM_TICK }); + + assert.calledOnce(instance.dispatchUpdateEvent); + assert.calledWith(instance.dispatchUpdateEvent, false, { + rows: [{ guid: "rec1" }, { guid: "rec2" }, { guid: "rec3" }], + topics: [ + { name: "topic1", url: "url-topic1" }, + { name: "topic2", url: "url-topic2" }, + ], + read_more_endpoint: undefined, + }); + }); + it("should not call init and uninit if data doesn't match on options change ", () => { + sinon.spy(instance, "init"); + sinon.spy(instance, "uninit"); + instance.onAction({ type: at.SECTION_OPTIONS_CHANGED, data: "foo" }); + assert.notCalled(sectionsManagerStub.disableSection); + assert.notCalled(sectionsManagerStub.enableSection); + assert.notCalled(instance.init); + assert.notCalled(instance.uninit); + }); + it("should call init and uninit on options change", async () => { + sinon.stub(instance, "clearCache").returns(Promise.resolve()); + sinon.spy(instance, "init"); + sinon.spy(instance, "uninit"); + await instance.onAction({ + type: at.SECTION_OPTIONS_CHANGED, + data: "topstories", + }); + assert.calledOnce(sectionsManagerStub.disableSection); + assert.calledOnce(sectionsManagerStub.enableSection); + assert.calledOnce(instance.clearCache); + assert.calledOnce(instance.init); + assert.calledOnce(instance.uninit); + }); + it("should set LastUpdated to 0 on init", async () => { + instance.storiesLastUpdated = 1; + instance.topicsLastUpdated = 1; + + await instance.onInit(); + assert.equal(instance.storiesLastUpdated, 0); + assert.equal(instance.topicsLastUpdated, 0); + }); + it("should filter spocs when link is blocked", async () => { + instance.spocs = [{ url: "not_blocked" }, { url: "blocked" }]; + await instance.onAction({ + type: at.PLACES_LINK_BLOCKED, + data: { url: "blocked" }, + }); + + assert.deepEqual(instance.spocs, [{ url: "not_blocked" }]); + }); + }); + describe("#loadCachedData", () => { + it("should update section with cached stories and topics if available", async () => { + sectionsManagerStub.sections.set("topstories", { + options: { stories_referrer: "referrer" }, + }); + const stories = { + _timestamp: 123, + recommendations: [ + { + id: "1", + title: "title", + excerpt: "description", + image_src: "image-url", + url: "rec-url", + published_timestamp: "123", + context: "trending", + icon: "icon", + item_score: 0.98, + }, + ], + }; + const transformedStories = [ + { + guid: "1", + type: "now", + title: "title", + context: "trending", + icon: "icon", + description: "description", + image: "image-url", + referrer: "referrer", + url: "rec-url", + hostname: "rec-url", + score: 0.98, + spoc_meta: {}, + }, + ]; + const topics = { + _timestamp: 123, + topics: [ + { name: "topic1", url: "url-topic1" }, + { name: "topic2", url: "url-topic2" }, + ], + }; + instance.cache.get = () => ({ stories, topics }); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + + await instance.onInit(); + assert.calledOnce(sectionsManagerStub.updateSection); + assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, { + rows: transformedStories, + topics: topics.topics, + read_more_endpoint: undefined, + }); + }); + it("should NOT update section if there is no cached data", async () => { + instance.cache.get = () => ({}); + globals.set("NewTabUtils", { + blockedLinks: { isBlocked: globals.sandbox.spy() }, + }); + await instance.loadCachedData(); + assert.notCalled(sectionsManagerStub.updateSection); + }); + it("should use store rows if no stories sent to doContentUpdate", async () => { + instance.store = { + getState() { + return { + Sections: [{ id: "topstories", rows: [1, 2, 3] }], + }; + }, + }; + sinon.spy(instance, "dispatchUpdateEvent"); + + instance.doContentUpdate({}, false); + + assert.calledOnce(instance.dispatchUpdateEvent); + assert.calledWith(instance.dispatchUpdateEvent, false, { + rows: [1, 2, 3], + }); + }); + it("should broadcast in doContentUpdate when updating from cache", async () => { + sectionsManagerStub.sections.set("topstories", { + options: { stories_referrer: "referrer" }, + }); + globals.set("NewTabUtils", { blockedLinks: { isBlocked: () => {} } }); + const stories = { recommendations: [{}] }; + const topics = { topics: [{}] }; + sinon.spy(instance, "doContentUpdate"); + instance.cache.get = () => ({ stories, topics }); + await instance.onInit(); + assert.calledOnce(instance.doContentUpdate); + assert.calledWith( + instance.doContentUpdate, + { + stories: [ + { + context: undefined, + description: undefined, + guid: undefined, + hostname: undefined, + icon: undefined, + image: undefined, + referrer: "referrer", + score: 1, + spoc_meta: {}, + title: undefined, + type: "trending", + url: undefined, + }, + ], + topics: [{}], + }, + true + ); + }); + }); + describe("#pocket", () => { + it("should call getPocketState when hitting NEW_TAB_REHYDRATED", () => { + instance.getPocketState = sinon.spy(); + instance.onAction({ + type: at.NEW_TAB_REHYDRATED, + meta: { fromTarget: {} }, + }); + assert.calledOnce(instance.getPocketState); + assert.calledWith(instance.getPocketState, {}); + }); + it("should call dispatch in getPocketState", () => { + const isUserLoggedIn = sinon.spy(); + globals.set("pktApi", { isUserLoggedIn }); + instance.getPocketState({}); + assert.calledOnce(instance.store.dispatch); + const [action] = instance.store.dispatch.firstCall.args; + assert.equal(action.type, "POCKET_LOGGED_IN"); + assert.calledOnce(isUserLoggedIn); + }); + it("should call dispatchPocketCta when hitting onInit", async () => { + instance.dispatchPocketCta = sinon.spy(); + await instance.onInit(); + assert.calledOnce(instance.dispatchPocketCta); + assert.calledWith( + instance.dispatchPocketCta, + JSON.stringify({ + cta_button: "", + cta_text: "", + cta_url: "", + use_cta: false, + }), + false + ); + }); + it("should call dispatch in dispatchPocketCta", () => { + instance.dispatchPocketCta(JSON.stringify({ use_cta: true }), false); + assert.calledOnce(instance.store.dispatch); + const [action] = instance.store.dispatch.firstCall.args; + assert.equal(action.type, "POCKET_CTA"); + assert.equal(action.data.use_cta, true); + }); + it("should call dispatchPocketCta with a pocketCta pref change", () => { + instance.dispatchPocketCta = sinon.spy(); + instance.onAction({ + type: at.PREF_CHANGED, + data: { + name: "pocketCta", + value: JSON.stringify({ + cta_button: "", + cta_text: "", + cta_url: "", + use_cta: false, + }), + }, + }); + assert.calledOnce(instance.dispatchPocketCta); + assert.calledWith( + instance.dispatchPocketCta, + JSON.stringify({ + cta_button: "", + cta_text: "", + cta_url: "", + use_cta: false, + }), + true + ); + }); + }); + it("should call uninit and init on disabling of showSponsored pref", async () => { + sinon.stub(instance, "clearCache").returns(Promise.resolve()); + sinon.stub(instance, "uninit"); + sinon.stub(instance, "init"); + await instance.onAction({ + type: at.PREF_CHANGED, + data: { name: "showSponsored", value: false }, + }); + assert.calledOnce(instance.clearCache); + assert.calledOnce(instance.uninit); + assert.calledOnce(instance.init); + }); +}); diff --git a/browser/components/newtab/test/unit/lib/UTEventReporting.test.js b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js new file mode 100644 index 0000000000..6255568438 --- /dev/null +++ b/browser/components/newtab/test/unit/lib/UTEventReporting.test.js @@ -0,0 +1,115 @@ +import { UTSessionPing, UTUserEventPing } from "test/schemas/pings"; +import { GlobalOverrider } from "test/unit/utils"; +import { UTEventReporting } from "lib/UTEventReporting.sys.mjs"; + +const FAKE_EVENT_PING_PC = { + event: "CLICK", + source: "TOP_SITES", + addon_version: "123", + user_prefs: 63, + session_id: "abc", + page: "about:newtab", + action_position: 5, + locale: "en-US", +}; +const FAKE_SESSION_PING_PC = { + session_duration: 1234, + addon_version: "123", + user_prefs: 63, + session_id: "abc", + page: "about:newtab", + locale: "en-US", +}; +const FAKE_EVENT_PING_UT = [ + "activity_stream", + "event", + "CLICK", + "TOP_SITES", + { + addon_version: "123", + user_prefs: "63", + session_id: "abc", + page: "about:newtab", + action_position: "5", + }, +]; +const FAKE_SESSION_PING_UT = [ + "activity_stream", + "end", + "session", + "1234", + { + addon_version: "123", + user_prefs: "63", + session_id: "abc", + page: "about:newtab", + }, +]; + +describe("UTEventReporting", () => { + let globals; + let sandbox; + let utEvents; + + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + sandbox.stub(global.Services.telemetry, "setEventRecordingEnabled"); + sandbox.stub(global.Services.telemetry, "recordEvent"); + + utEvents = new UTEventReporting(); + }); + + afterEach(() => { + globals.restore(); + }); + + describe("#sendUserEvent()", () => { + it("should queue up the correct data to send to Events Telemetry", async () => { + utEvents.sendUserEvent(FAKE_EVENT_PING_PC); + assert.calledWithExactly( + global.Services.telemetry.recordEvent, + ...FAKE_EVENT_PING_UT + ); + + let ping = global.Services.telemetry.recordEvent.firstCall.args; + assert.validate(ping, UTUserEventPing); + }); + }); + + describe("#sendSessionEndEvent()", () => { + it("should queue up the correct data to send to Events Telemetry", async () => { + utEvents.sendSessionEndEvent(FAKE_SESSION_PING_PC); + assert.calledWithExactly( + global.Services.telemetry.recordEvent, + ...FAKE_SESSION_PING_UT + ); + + let ping = global.Services.telemetry.recordEvent.firstCall.args; + assert.validate(ping, UTSessionPing); + }); + }); + + describe("#uninit()", () => { + it("should call setEventRecordingEnabled with a false value", () => { + assert.equal( + global.Services.telemetry.setEventRecordingEnabled.firstCall.args[0], + "activity_stream" + ); + assert.equal( + global.Services.telemetry.setEventRecordingEnabled.firstCall.args[1], + true + ); + + utEvents.uninit(); + assert.equal( + global.Services.telemetry.setEventRecordingEnabled.secondCall.args[0], + "activity_stream" + ); + assert.equal( + global.Services.telemetry.setEventRecordingEnabled.secondCall.args[1], + false + ); + }); + }); +}); diff --git a/browser/components/newtab/test/unit/unit-entry.js b/browser/components/newtab/test/unit/unit-entry.js new file mode 100644 index 0000000000..803390a386 --- /dev/null +++ b/browser/components/newtab/test/unit/unit-entry.js @@ -0,0 +1,684 @@ +import { + EventEmitter, + FakePrefs, + FakensIPrefService, + GlobalOverrider, + FakeConsoleAPI, + FakeLogger, +} from "test/unit/utils"; +import Adapter from "enzyme-adapter-react-16"; +import { chaiAssertions } from "test/schemas/pings"; +import chaiJsonSchema from "chai-json-schema"; +import enzyme from "enzyme"; +import FxMSCommonSchema from "../../content-src/asrouter/schemas/FxMSCommon.schema.json"; + +enzyme.configure({ adapter: new Adapter() }); + +// Cause React warnings to make tests that trigger them fail +const origConsoleError = console.error; +console.error = function (msg, ...args) { + origConsoleError.apply(console, [msg, ...args]); + + if ( + /(Invalid prop|Failed prop type|Check the render method|React Intl)/.test( + msg + ) + ) { + throw new Error(msg); + } +}; + +const req = require.context(".", true, /\.test\.jsx?$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, { prefix: "" }); + +chai.use(chaiAssertions); +chai.use(chaiJsonSchema); +chai.tv4.addSchema("file:///FxMSCommon.schema.json", FxMSCommonSchema); + +const overrider = new GlobalOverrider(); + +const RemoteSettings = name => ({ + get: () => { + if (name === "attachment") { + return Promise.resolve([{ attachment: {} }]); + } + return Promise.resolve([]); + }, + on: () => {}, + off: () => {}, +}); +RemoteSettings.pollChanges = () => {}; + +class JSWindowActorParent { + sendAsyncMessage(name, data) { + return { name, data }; + } +} + +class JSWindowActorChild { + sendAsyncMessage(name, data) { + return { name, data }; + } + + sendQuery(name, data) { + return Promise.resolve({ name, data }); + } + + get contentWindow() { + return { + Promise, + }; + } +} + +// Detect plain object passed to lazy getter APIs, and set its prototype to +// global object, and return the global object for further modification. +// Returns the object if it's not plain object. +// +// This is a workaround to make the existing testharness and testcase keep +// working even after lazy getters are moved to plain `lazy` object. +const cachedPlainObject = new Set(); +function updateGlobalOrObject(object) { + // Given this function modifies the prototype, and the following + // condition doesn't meet on the second call, cache the result. + if (cachedPlainObject.has(object)) { + return global; + } + + if (Object.getPrototypeOf(object).constructor.name !== "Object") { + return object; + } + + cachedPlainObject.add(object); + Object.setPrototypeOf(object, global); + return global; +} + +const TEST_GLOBAL = { + JSWindowActorParent, + JSWindowActorChild, + AboutReaderParent: { + addMessageListener: (messageName, listener) => {}, + removeMessageListener: (messageName, listener) => {}, + }, + AboutWelcomeTelemetry: class { + submitGleanPingForPing() {} + }, + AddonManager: { + getActiveAddons() { + return Promise.resolve({ addons: [], fullData: false }); + }, + }, + AppConstants: { + MOZILLA_OFFICIAL: true, + MOZ_APP_VERSION: "69.0a1", + isChinaRepack() { + return false; + }, + isPlatformAndVersionAtMost() { + return false; + }, + platform: "win", + }, + ASRouterPreferences: { + console: new FakeConsoleAPI({ + maxLogLevel: "off", // set this to "debug" or "all" to get more ASRouter logging in tests + prefix: "ASRouter", + }), + }, + AWScreenUtils: { + evaluateTargetingAndRemoveScreens() { + return true; + }, + async removeScreens() { + return true; + }, + evaluateScreenTargeting() { + return true; + }, + }, + BrowserUtils: { + sendToDeviceEmailsSupported() { + return true; + }, + }, + UpdateUtils: { getUpdateChannel() {} }, + BasePromiseWorker: class { + constructor() { + this.ExceptionHandlers = []; + } + post() {} + }, + browserSearchRegion: "US", + BrowserWindowTracker: { getTopWindow() {} }, + ChromeUtils: { + defineModuleGetter: updateGlobalOrObject, + defineESModuleGetters: updateGlobalOrObject, + generateQI() { + return {}; + }, + import() { + return global; + }, + importESModule() { + return global; + }, + }, + ClientEnvironment: { + get userId() { + return "foo123"; + }, + }, + Components: { + Constructor(classId) { + switch (classId) { + case "@mozilla.org/referrer-info;1": + return function (referrerPolicy, sendReferrer, originalReferrer) { + this.referrerPolicy = referrerPolicy; + this.sendReferrer = sendReferrer; + this.originalReferrer = originalReferrer; + }; + } + return function () {}; + }, + isSuccessCode: () => true, + }, + ConsoleAPI: FakeConsoleAPI, + // NB: These are functions/constructors + // eslint-disable-next-line object-shorthand + ContentSearchUIController: function () {}, + // eslint-disable-next-line object-shorthand + ContentSearchHandoffUIController: function () {}, + Cc: { + "@mozilla.org/browser/nav-bookmarks-service;1": { + addObserver() {}, + getService() { + return this; + }, + removeObserver() {}, + SOURCES: {}, + TYPE_BOOKMARK: {}, + }, + "@mozilla.org/browser/nav-history-service;1": { + addObserver() {}, + executeQuery() {}, + getNewQuery() {}, + getNewQueryOptions() {}, + getService() { + return this; + }, + insert() {}, + markPageAsTyped() {}, + removeObserver() {}, + }, + "@mozilla.org/io/string-input-stream;1": { + createInstance() { + return {}; + }, + }, + "@mozilla.org/security/hash;1": { + createInstance() { + return { + init() {}, + updateFromStream() {}, + finish() { + return "0"; + }, + }; + }, + }, + "@mozilla.org/updates/update-checker;1": { createInstance() {} }, + "@mozilla.org/widget/useridleservice;1": { + getService() { + return { + idleTime: 0, + addIdleObserver() {}, + removeIdleObserver() {}, + }; + }, + }, + "@mozilla.org/streamConverters;1": { + getService() { + return this; + }, + }, + "@mozilla.org/network/stream-loader;1": { + createInstance() { + return {}; + }, + }, + }, + Ci: { + nsICryptoHash: {}, + nsIReferrerInfo: { UNSAFE_URL: 5 }, + nsITimer: { TYPE_ONE_SHOT: 1 }, + nsIWebProgressListener: { LOCATION_CHANGE_SAME_DOCUMENT: 1 }, + nsIDOMWindow: Object, + nsITrackingDBService: { + TRACKERS_ID: 1, + TRACKING_COOKIES_ID: 2, + CRYPTOMINERS_ID: 3, + FINGERPRINTERS_ID: 4, + SOCIAL_ID: 5, + }, + nsICookieBannerService: { + MODE_DISABLED: 0, + MODE_REJECT: 1, + MODE_REJECT_OR_ACCEPT: 2, + MODE_UNSET: 3, + }, + }, + Cu: { + importGlobalProperties() {}, + now: () => window.performance.now(), + cloneInto: o => JSON.parse(JSON.stringify(o)), + }, + console: { + ...console, + error() {}, + }, + dump() {}, + EveryWindow: { + registerCallback: (id, init, uninit) => {}, + unregisterCallback: id => {}, + }, + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + fetch() {}, + // eslint-disable-next-line object-shorthand + Image: function () {}, // NB: This is a function/constructor + IOUtils: { + writeJSON() { + return Promise.resolve(0); + }, + readJSON() { + return Promise.resolve({}); + }, + read() { + return Promise.resolve(new Uint8Array()); + }, + makeDirectory() { + return Promise.resolve(0); + }, + write() { + return Promise.resolve(0); + }, + exists() { + return Promise.resolve(0); + }, + remove() { + return Promise.resolve(0); + }, + stat() { + return Promise.resolve(0); + }, + }, + NewTabUtils: { + activityStreamProvider: { + getTopFrecentSites: () => [], + executePlacesQuery: async (sql, options) => ({ sql, options }), + }, + }, + OS: { + File: { + writeAtomic() {}, + makeDir() {}, + stat() {}, + Error: {}, + read() {}, + exists() {}, + remove() {}, + removeEmptyDir() {}, + }, + Path: { + join() { + return "/"; + }, + }, + Constants: { + Path: { + localProfileDir: "/", + }, + }, + }, + PathUtils: { + join(...parts) { + return parts[parts.length - 1]; + }, + joinRelative(...parts) { + return parts[parts.length - 1]; + }, + getProfileDir() { + return Promise.resolve("/"); + }, + getLocalProfileDir() { + return Promise.resolve("/"); + }, + }, + PlacesUtils: { + get bookmarks() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-bookmarks-service;1"]; + }, + get history() { + return TEST_GLOBAL.Cc["@mozilla.org/browser/nav-history-service;1"]; + }, + observers: { + addListener() {}, + removeListener() {}, + }, + }, + PluralForm: { get() {} }, + Preferences: FakePrefs, + PrivateBrowsingUtils: { + isBrowserPrivate: () => false, + isWindowPrivate: () => false, + permanentPrivateBrowsing: false, + }, + DownloadsViewUI: { + getDisplayName: () => "filename.ext", + getSizeWithUnits: () => "1.5 MB", + }, + FileUtils: { + // eslint-disable-next-line object-shorthand + File: function () {}, // NB: This is a function/constructor + }, + Region: { + home: "US", + REGION_TOPIC: "browser-region-updated", + }, + Services: { + dirsvc: { + get: () => ({ parent: { parent: { path: "appPath" } } }), + }, + env: { + set: () => undefined, + }, + locale: { + get appLocaleAsBCP47() { + return "en-US"; + }, + negotiateLanguages() {}, + }, + urlFormatter: { formatURL: str => str, formatURLPref: str => str }, + mm: { + addMessageListener: (msg, cb) => this.receiveMessage(), + removeMessageListener() {}, + }, + obs: { + addObserver() {}, + removeObserver() {}, + notifyObservers() {}, + }, + telemetry: { + setEventRecordingEnabled: () => {}, + recordEvent: eventDetails => {}, + scalarSet: () => {}, + keyedScalarAdd: () => {}, + }, + uuid: { + generateUUID() { + return "{foo-123-foo}"; + }, + }, + console: { logStringMessage: () => {} }, + prefs: new FakensIPrefService(), + tm: { + dispatchToMainThread: cb => cb(), + idleDispatchToMainThread: cb => cb(), + }, + eTLD: { + getBaseDomain({ spec }) { + return spec.match(/\/([^/]+)/)[1]; + }, + getBaseDomainFromHost(host) { + return host.match(/.*?(\w+\.\w+)$/)[1]; + }, + getPublicSuffix() {}, + }, + io: { + newURI: spec => ({ + mutate: () => ({ + setRef: ref => ({ + finalize: () => ({ + ref, + spec, + }), + }), + }), + spec, + }), + }, + search: { + init() { + return Promise.resolve(); + }, + getVisibleEngines: () => + Promise.resolve([{ identifier: "google" }, { identifier: "bing" }]), + defaultEngine: { + identifier: "google", + searchForm: + "https://www.google.com/search?q=&ie=utf-8&oe=utf-8&client=firefox-b", + aliases: ["@google"], + }, + defaultPrivateEngine: { + identifier: "bing", + searchForm: "https://www.bing.com", + aliases: ["@bing"], + }, + getEngineByAlias: async () => null, + }, + scriptSecurityManager: { + createNullPrincipal() {}, + getSystemPrincipal() {}, + }, + wm: { + getMostRecentWindow: () => window, + getMostRecentBrowserWindow: () => window, + getEnumerator: () => [], + }, + ww: { registerNotification() {}, unregisterNotification() {} }, + appinfo: { appBuildID: "20180710100040", version: "69.0a1" }, + scriptloader: { loadSubScript: () => {} }, + startup: { + getStartupInfo() { + return { + process: { + getTime() { + return 1588010448000; + }, + }, + }; + }, + }, + }, + XPCOMUtils: { + defineLazyGetter(object, name, f) { + updateGlobalOrObject(object)[name] = f(); + }, + defineLazyGlobalGetters: updateGlobalOrObject, + defineLazyModuleGetter: updateGlobalOrObject, + defineLazyModuleGetters: updateGlobalOrObject, + defineLazyServiceGetter: updateGlobalOrObject, + defineLazyServiceGetters: updateGlobalOrObject, + defineLazyPreferenceGetter(object, name) { + updateGlobalOrObject(object)[name] = ""; + }, + generateQI() { + return {}; + }, + }, + EventEmitter, + ShellService: { + doesAppNeedPin: () => false, + isDefaultBrowser: () => true, + }, + FilterExpressions: { + eval() { + return Promise.resolve(false); + }, + }, + RemoteSettings, + Localization: class { + async formatMessages(stringsIds) { + return Promise.resolve( + stringsIds.map(({ id, args }) => ({ value: { string_id: id, args } })) + ); + } + async formatValue(stringId) { + return Promise.resolve(stringId); + } + }, + FxAccountsConfig: { + promiseConnectAccountURI(id) { + return Promise.resolve(id); + }, + }, + FX_MONITOR_OAUTH_CLIENT_ID: "fake_client_id", + ExperimentAPI: { + getExperiment() {}, + getExperimentMetaData() {}, + getRolloutMetaData() {}, + }, + NimbusFeatures: { + glean: { + getVariable() {}, + }, + newtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + pocketNewtab: { + getVariable() {}, + getAllVariables() {}, + onUpdate() {}, + offUpdate() {}, + }, + cookieBannerHandling: { + getVariable() {}, + }, + }, + TelemetryEnvironment: { + setExperimentActive() {}, + currentEnvironment: { + profile: { + creationDate: 16587, + }, + settings: {}, + }, + }, + TelemetryStopwatch: { + start: () => {}, + finish: () => {}, + }, + Sampling: { + ratioSample(seed, ratios) { + return Promise.resolve(0); + }, + }, + BrowserHandler: { + get kiosk() { + return false; + }, + }, + TelemetrySession: { + getMetadata(reason) { + return { + reason, + sessionId: "fake_session_id", + }; + }, + }, + PageThumbs: { + addExpirationFilter() {}, + removeExpirationFilter() {}, + }, + Logger: FakeLogger, + getFxAccountsSingleton() {}, + AboutNewTab: {}, + Glean: { + newtab: { + opened: { + record() {}, + }, + closed: { + record() {}, + }, + locale: { + set() {}, + }, + newtabCategory: { + set() {}, + }, + homepageCategory: { + set() {}, + }, + blockedSponsors: { + set() {}, + }, + }, + newtabSearch: { + enabled: { + set() {}, + }, + }, + pocket: { + enabled: { + set() {}, + }, + impression: { + record() {}, + }, + isSignedIn: { + set() {}, + }, + sponsoredStoriesEnabled: { + set() {}, + }, + click: { + record() {}, + }, + save: { + record() {}, + }, + topicClick: { + record() {}, + }, + }, + topsites: { + enabled: { + set() {}, + }, + sponsoredEnabled: { + set() {}, + }, + impression: { + record() {}, + }, + click: { + record() {}, + }, + rows: { + set() {}, + }, + }, + }, + GleanPings: { + newtab: { + submit() {}, + }, + }, + Utils: { + SERVER_URL: "bogus://foo", + }, +}; +overrider.set(TEST_GLOBAL); + +describe("activity-stream", () => { + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); diff --git a/browser/components/newtab/test/unit/utils.js b/browser/components/newtab/test/unit/utils.js new file mode 100644 index 0000000000..22069b8635 --- /dev/null +++ b/browser/components/newtab/test/unit/utils.js @@ -0,0 +1,406 @@ +/** + * GlobalOverrider - Utility that allows you to override properties on the global object. + * See unit-entry.js for example usage. + */ +export class GlobalOverrider { + constructor() { + this.originalGlobals = new Map(); + this.sandbox = sinon.createSandbox(); + } + + /** + * _override - Internal method to override properties on the global object. + * The first time a given key is overridden, we cache the original + * value in this.originalGlobals so that later it can be restored. + * + * @param {string} key The identifier of the property + * @param {any} value The value to which the property should be reassigned + */ + _override(key, value) { + if (!this.originalGlobals.has(key)) { + this.originalGlobals.set(key, global[key]); + } + global[key] = value; + } + + /** + * set - Override a given property, or all properties on an object + * + * @param {string|object} key If a string, the identifier of the property + * If an object, a number of properties and values to which they should be reassigned. + * @param {any} value The value to which the property should be reassigned + * @return {type} description + */ + set(key, value) { + if (!value && typeof key === "object") { + const overrides = key; + Object.keys(overrides).forEach(k => this._override(k, overrides[k])); + } else { + this._override(key, value); + } + return value; + } + + /** + * reset - Reset the global sandbox, so all state on spies, stubs etc. is cleared. + * You probably want to call this after each test. + */ + reset() { + this.sandbox.reset(); + } + + /** + * restore - Restore the global sandbox and reset all overriden properties to + * their original values. You should call this after all tests have completed. + */ + restore() { + this.sandbox.restore(); + this.originalGlobals.forEach((value, key) => { + global[key] = value; + }); + } +} + +/** + * A map of mocked preference names and values, used by `FakensIPrefBranch`, + * `FakensIPrefService`, and `FakePrefs`. + * + * Tests should add entries to this map for any preferences they'd like to set, + * and remove any entries during teardown for preferences that shouldn't be + * shared between tests. + */ +export const FAKE_GLOBAL_PREFS = new Map(); + +/** + * Very simple fake for the most basic semantics of nsIPrefBranch. Lots of + * things aren't yet supported. Feel free to add them in. + * + * @param {Object} args - optional arguments + * @param {Function} args.initHook - if present, will be called back + * inside the constructor. Typically used from tests + * to save off a pointer to the created instance so that + * stubs and spies can be inspected by the test code. + */ +export class FakensIPrefBranch { + PREF_INVALID = "invalid"; + PREF_INT = "integer"; + PREF_BOOL = "boolean"; + PREF_STRING = "string"; + + constructor(args) { + if (args) { + if ("initHook" in args) { + args.initHook.call(this); + } + if (args.defaultBranch) { + this.prefs = new Map(); + } else { + this.prefs = FAKE_GLOBAL_PREFS; + } + } else { + this.prefs = FAKE_GLOBAL_PREFS; + } + this._prefBranch = {}; + this.observers = new Map(); + } + addObserver(prefix, callback) { + this.observers.set(prefix, callback); + } + removeObserver(prefix, callback) { + this.observers.delete(prefix, callback); + } + setStringPref(prefName, value) { + this.set(prefName, value); + } + getStringPref(prefName, defaultValue) { + return this.get(prefName, defaultValue); + } + setBoolPref(prefName, value) { + this.set(prefName, value); + } + getBoolPref(prefName) { + return this.get(prefName); + } + setIntPref(prefName, value) { + this.set(prefName, value); + } + getIntPref(prefName) { + return this.get(prefName); + } + setCharPref(prefName, value) { + this.set(prefName, value); + } + getCharPref(prefName) { + return this.get(prefName); + } + clearUserPref(prefName) { + this.prefs.delete(prefName); + } + get(prefName, defaultValue) { + let value = this.prefs.get(prefName); + return typeof value === "undefined" ? defaultValue : value; + } + getPrefType(prefName) { + let value = this.prefs.get(prefName); + switch (typeof value) { + case "number": + return this.PREF_INT; + + case "boolean": + return this.PREF_BOOL; + + case "string": + return this.PREF_STRING; + + default: + return this.PREF_INVALID; + } + } + set(prefName, value) { + this.prefs.set(prefName, value); + + // Trigger all observers for prefixes of the changed pref name. This matches + // the semantics of `nsIPrefBranch`. + let observerPrefixes = [...this.observers.keys()].filter(prefix => + prefName.startsWith(prefix) + ); + for (let observerPrefix of observerPrefixes) { + this.observers.get(observerPrefix)("", "", prefName); + } + } + getChildList(prefix) { + return [...this.prefs.keys()].filter(prefName => + prefName.startsWith(prefix) + ); + } + prefHasUserValue(prefName) { + return this.prefs.has(prefName); + } + prefIsLocked(prefName) { + return false; + } +} + +/** + * A fake `Services.prefs` implementation that extends `FakensIPrefBranch` + * with methods specific to `nsIPrefService`. + */ +export class FakensIPrefService extends FakensIPrefBranch { + getBranch() {} + getDefaultBranch(prefix) { + return { + setBoolPref() {}, + setIntPref() {}, + setStringPref() {}, + clearUserPref() {}, + }; + } +} + +/** + * Very simple fake for the most basic semantics of Preferences.sys.mjs. + * Extends FakensIPrefBranch. + */ +export class FakePrefs extends FakensIPrefBranch { + observe(prefName, callback) { + super.addObserver(prefName, callback); + } + ignore(prefName, callback) { + super.removeObserver(prefName, callback); + } + observeBranch(listener) {} + ignoreBranch(listener) {} + set(prefName, value) { + this.prefs.set(prefName, value); + + // Trigger observers for just the changed pref name, not any of its + // prefixes. This matches the semantics of `Preferences.sys.mjs`. + if (this.observers.has(prefName)) { + this.observers.get(prefName)(value); + } + } +} + +/** + * Slimmed down version of toolkit/modules/EventEmitter.sys.mjs + */ +export function EventEmitter() {} +EventEmitter.decorate = function (objectToDecorate) { + let emitter = new EventEmitter(); + objectToDecorate.on = emitter.on.bind(emitter); + objectToDecorate.off = emitter.off.bind(emitter); + objectToDecorate.once = emitter.once.bind(emitter); + objectToDecorate.emit = emitter.emit.bind(emitter); +}; +EventEmitter.prototype = { + on(event, listener) { + if (!this._eventEmitterListeners) { + this._eventEmitterListeners = new Map(); + } + if (!this._eventEmitterListeners.has(event)) { + this._eventEmitterListeners.set(event, []); + } + this._eventEmitterListeners.get(event).push(listener); + }, + off(event, listener) { + if (!this._eventEmitterListeners) { + return; + } + let listeners = this._eventEmitterListeners.get(event); + if (listeners) { + this._eventEmitterListeners.set( + event, + listeners.filter( + l => l !== listener && l._originalListener !== listener + ) + ); + } + }, + once(event, listener) { + return new Promise(resolve => { + let handler = (_, first, ...rest) => { + this.off(event, handler); + if (listener) { + listener(event, first, ...rest); + } + resolve(first); + }; + + handler._originalListener = listener; + this.on(event, handler); + }); + }, + // All arguments to this method will be sent to listeners + emit(event, ...args) { + if ( + !this._eventEmitterListeners || + !this._eventEmitterListeners.has(event) + ) { + return; + } + let originalListeners = this._eventEmitterListeners.get(event); + for (let listener of this._eventEmitterListeners.get(event)) { + // If the object was destroyed during event emission, stop + // emitting. + if (!this._eventEmitterListeners) { + break; + } + // If listeners were removed during emission, make sure the + // event handler we're going to fire wasn't removed. + if ( + originalListeners === this._eventEmitterListeners.get(event) || + this._eventEmitterListeners.get(event).some(l => l === listener) + ) { + try { + listener(event, ...args); + } catch (ex) { + // error with a listener + } + } + } + }, +}; + +export function FakePerformance() {} +FakePerformance.prototype = { + marks: new Map(), + now() { + return window.performance.now(); + }, + timing: { navigationStart: 222222.123 }, + get timeOrigin() { + return 10000.234; + }, + // XXX assumes type == "mark" + getEntriesByName(name, type) { + if (this.marks.has(name)) { + return this.marks.get(name); + } + return []; + }, + callsToMark: 0, + + /** + * @note The "startTime" for each mark is simply the number of times mark + * has been called in this object. + */ + mark(name) { + let markObj = { + name, + entryType: "mark", + startTime: ++this.callsToMark, + duration: 0, + }; + + if (this.marks.has(name)) { + this.marks.get(name).push(markObj); + return; + } + + this.marks.set(name, [markObj]); + }, +}; + +/** + * addNumberReducer - a simple dummy reducer for testing that adds a number + */ +export function addNumberReducer(prevState = 0, action) { + return action.type === "ADD" ? prevState + action.data : prevState; +} + +export class FakeConsoleAPI { + static LOG_LEVELS = { + all: Number.MIN_VALUE, + debug: 2, + log: 3, + info: 3, + clear: 3, + trace: 3, + timeEnd: 3, + time: 3, + assert: 3, + group: 3, + groupEnd: 3, + profile: 3, + profileEnd: 3, + dir: 3, + dirxml: 3, + warn: 4, + error: 5, + off: Number.MAX_VALUE, + }; + + constructor({ prefix = "", maxLogLevel = "all" } = {}) { + this.prefix = prefix; + this.prefixStr = prefix ? `${prefix}: ` : ""; + this.maxLogLevel = maxLogLevel; + + for (const level of Object.keys(FakeConsoleAPI.LOG_LEVELS)) { + // eslint-disable-next-line no-console + if (typeof console[level] === "function") { + this[level] = this.shouldLog(level) + ? this._log.bind(this, level) + : () => {}; + } + } + } + shouldLog(level) { + return ( + FakeConsoleAPI.LOG_LEVELS[this.maxLogLevel] <= + FakeConsoleAPI.LOG_LEVELS[level] + ); + } + _log(level, ...args) { + console[level](this.prefixStr, ...args); // eslint-disable-line no-console + } +} + +export class FakeLogger extends FakeConsoleAPI { + constructor() { + super({ + // Don't use a prefix because the first instance gets cached and reused by + // other consumers that would otherwise pass their own identifying prefix. + maxLogLevel: "off", // Change this to "debug" or "all" to get more logging in tests + }); + } +} |