summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/test/unit/content-src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/test/unit/content-src/lib')
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/detect-user-session-start.test.js120
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/init-store.test.js207
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/perf-service.test.js89
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/screenshot-utils.test.js147
-rw-r--r--browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js576
5 files changed, 1139 insertions, 0 deletions
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,
+ });
+ });
+});