From 293f8ac4f50ee08f4dd3292518c3c096ce1f0193 Mon Sep 17 00:00:00 2001 From: Ed Lee Date: Tue, 25 Apr 2017 13:31:29 -0700 Subject: [PATCH] Bug 1359481 - Add top sites and search to activity-stream system add-on. r=Mardak --- .eslintignore | 3 +- .../extensions/activity-stream/bootstrap.js | 32 +- .../activity-stream/common/Actions.jsm | 14 +- .../activity-stream/common/Reducers.jsm | 79 +- .../data/content/activity-stream.bundle.js | 570 +++++++++++ .../data/content/activity-stream.css | 334 ++++++ .../data/content/activity-stream.html | 31 +- .../content/assets/glyph-forward-16-white.svg | 7 + .../data/content/assets/glyph-forward-16.svg | 7 + .../data/content/assets/glyph-search-16.svg | 13 + .../content/assets/glyph-search-history.svg | 19 + browser/extensions/activity-stream/jar.mn | 4 + .../activity-stream/lib/ActivityStream.jsm | 31 +- .../lib/ActivityStreamMessageChannel.jsm | 11 +- .../activity-stream/lib/SearchFeed.jsm | 65 ++ .../extensions/activity-stream/lib/Store.jsm | 101 +- .../activity-stream/lib/TopSitesFeed.jsm | 83 ++ .../activity-stream/test/.eslintrc.js | 11 + .../test/functional/mochitest/.eslintrc.js | 42 + .../test/functional/mochitest/browser.ini | 13 + .../mochitest/browser_as_load_location.js | 34 + .../mochitest/browser_dummy_test.js | 34 + .../activity-stream/test/mozinfo.json | 3 + .../test/unit/common/Actions.test.js | 93 ++ .../test/unit/common/Reducers.test.js | 51 + .../test/unit/lib/ActivityStream.test.js | 70 ++ .../lib/ActivityStreamMessageChannel.test.js | 235 +++++ .../test/unit/lib/SearchFeed.test.js | 77 ++ .../test/unit/lib/Store.test.js | 210 ++++ .../test/unit/lib/TelemetrySender.test.js | 271 +++++ .../test/unit/lib/TopSitesFeed.test.js | 116 +++ .../test/unit/lib/init-store.test.js | 43 + .../activity-stream/test/unit/unit-entry.js | 38 + .../activity-stream/test/unit/utils.js | 122 +++ .../activity-stream/vendor/redux.js | 948 ++++++++++++++++++ 35 files changed, 3724 insertions(+), 91 deletions(-) create mode 100644 browser/extensions/activity-stream/data/content/activity-stream.bundle.js create mode 100644 browser/extensions/activity-stream/data/content/activity-stream.css create mode 100644 browser/extensions/activity-stream/data/content/assets/glyph-forward-16-white.svg create mode 100644 browser/extensions/activity-stream/data/content/assets/glyph-forward-16.svg create mode 100644 browser/extensions/activity-stream/data/content/assets/glyph-search-16.svg create mode 100644 browser/extensions/activity-stream/data/content/assets/glyph-search-history.svg create mode 100644 browser/extensions/activity-stream/lib/SearchFeed.jsm create mode 100644 browser/extensions/activity-stream/lib/TopSitesFeed.jsm create mode 100644 browser/extensions/activity-stream/test/.eslintrc.js create mode 100644 browser/extensions/activity-stream/test/functional/mochitest/.eslintrc.js create mode 100644 browser/extensions/activity-stream/test/functional/mochitest/browser.ini create mode 100644 browser/extensions/activity-stream/test/functional/mochitest/browser_as_load_location.js create mode 100644 browser/extensions/activity-stream/test/functional/mochitest/browser_dummy_test.js create mode 100644 browser/extensions/activity-stream/test/mozinfo.json create mode 100644 browser/extensions/activity-stream/test/unit/common/Actions.test.js create mode 100644 browser/extensions/activity-stream/test/unit/common/Reducers.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/ActivityStreamMessageChannel.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/SearchFeed.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/Store.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js create mode 100644 browser/extensions/activity-stream/test/unit/lib/init-store.test.js create mode 100644 browser/extensions/activity-stream/test/unit/unit-entry.js create mode 100644 browser/extensions/activity-stream/test/unit/utils.js create mode 100644 browser/extensions/activity-stream/vendor/redux.js diff --git a/.eslintignore b/.eslintignore index 951a50edeee8..d48a8e380996 100644 --- a/.eslintignore +++ b/.eslintignore @@ -72,7 +72,8 @@ browser/extensions/pdfjs/content/web** browser/extensions/pocket/content/panels/js/tmpl.js browser/extensions/pocket/content/panels/js/vendor/** browser/locales/** -# vendor library files in activity-stream +# generated or library files in activity-stream +browser/extensions/activity-stream/data/content/activity-stream.bundle.js browser/extensions/activity-stream/vendor/** # imported from chromium browser/extensions/mortar/** diff --git a/browser/extensions/activity-stream/bootstrap.js b/browser/extensions/activity-stream/bootstrap.js index 2d26688bdf32..e22c79d5c817 100644 --- a/browser/extensions/activity-stream/bootstrap.js +++ b/browser/extensions/activity-stream/bootstrap.js @@ -1,16 +1,18 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/* globals Components, XPCOMUtils, Preferences, ActivityStream */ +/* globals Components, XPCOMUtils, Preferences, Services, ActivityStream */ "use strict"; const {utils: Cu} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "ActivityStream", "resource://activity-stream/lib/ActivityStream.jsm"); +const BROWSER_READY_NOTIFICATION = "browser-ui-startup-complete"; const ACTIVITY_STREAM_ENABLED_PREF = "browser.newtabpage.activity-stream.enabled"; const REASON_STARTUP_ON_PREF_CHANGE = "PREF_ON"; const REASON_SHUTDOWN_ON_PREF_CHANGE = "PREF_OFF"; @@ -19,6 +21,7 @@ const ACTIVITY_STREAM_OPTIONS = {newTabURL: "about:newtab"}; let activityStream; let startupData; +let startupReason; /** * init - Initializes an instance of ActivityStream. This could be called by @@ -64,27 +67,38 @@ function onPrefChanged(isEnabled) { } } +function observe(subject, topic, data) { + switch (topic) { + case BROWSER_READY_NOTIFICATION: + // Listen for changes to the pref that enables Activity Stream + Preferences.observe(ACTIVITY_STREAM_ENABLED_PREF, onPrefChanged); + // Only initialize if the pref is true + if (Preferences.get(ACTIVITY_STREAM_ENABLED_PREF)) { + init(startupReason); + Services.obs.removeObserver(this, BROWSER_READY_NOTIFICATION); + } + break; + } +} + // The functions below are required by bootstrap.js this.install = function install(data, reason) {}; this.startup = function startup(data, reason) { + // Only start Activity Stream up when the browser UI is ready + Services.obs.addObserver(observe, BROWSER_READY_NOTIFICATION); + // Cache startup data which contains stuff like the version number, etc. // so we can use it when we init startupData = data; - - // Listen for changes to the pref that enables Activity Stream - Preferences.observe(ACTIVITY_STREAM_ENABLED_PREF, onPrefChanged); - - // Only initialize if the pref is true - if (Preferences.get(ACTIVITY_STREAM_ENABLED_PREF)) { - init(reason); - } + startupReason = reason; }; this.shutdown = function shutdown(data, reason) { // Uninitialize Activity Stream startupData = null; + startupReason = null; uninit(reason); // Stop listening to the pref that enables Activity Stream diff --git a/browser/extensions/activity-stream/common/Actions.jsm b/browser/extensions/activity-stream/common/Actions.jsm index bb1654a85b39..56ac20e88834 100644 --- a/browser/extensions/activity-stream/common/Actions.jsm +++ b/browser/extensions/activity-stream/common/Actions.jsm @@ -3,15 +3,19 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; -this.MAIN_MESSAGE_TYPE = "ActivityStream:Main"; -this.CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; +const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; +const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; -this.actionTypes = [ +const actionTypes = [ "INIT", "UNINIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", - "NEW_TAB_UNLOAD" + "NEW_TAB_UNLOAD", + "PERFORM_SEARCH", + "SCREENSHOT_UPDATED", + "SEARCH_STATE_UPDATED", + "TOP_SITES_UPDATED" // The line below creates an object like this: // { // INIT: "INIT", @@ -86,6 +90,8 @@ function SendToContent(action, target) { }); } +this.actionTypes = actionTypes; + this.actionCreators = { SendToMain, SendToContent, diff --git a/browser/extensions/activity-stream/common/Reducers.jsm b/browser/extensions/activity-stream/common/Reducers.jsm index 33b6db8a44d4..048aa1112afe 100644 --- a/browser/extensions/activity-stream/common/Reducers.jsm +++ b/browser/extensions/activity-stream/common/Reducers.jsm @@ -3,42 +3,63 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; -this.INITIAL_STATE = { +const {actionTypes: at} = Components.utils.import("resource://activity-stream/common/Actions.jsm", {}); + +const INITIAL_STATE = { TopSites: { - rows: [ - { - "title": "Facebook", - "url": "https://www.facebook.com/" - }, - { - "title": "YouTube", - "url": "https://www.youtube.com/" - }, - { - "title": "Amazon", - "url": "http://www.amazon.com/" - }, - { - "title": "Yahoo", - "url": "https://www.yahoo.com/" - }, - { - "title": "eBay", - "url": "http://www.ebay.com" - }, - { - "title": "Twitter", - "url": "https://twitter.com/" - } - ] + init: false, + rows: [] + }, + Search: { + currentEngine: { + name: "", + icon: "" + }, + engines: [] } }; // TODO: Handle some real actions here, once we have a TopSites feed working function TopSites(prevState = INITIAL_STATE.TopSites, action) { - return prevState; + let hasMatch; + let newRows; + switch (action.type) { + case at.TOP_SITES_UPDATED: + if (!action.data) { + return prevState; + } + return Object.assign({}, prevState, {init: true, rows: action.data}); + case at.SCREENSHOT_UPDATED: + newRows = prevState.rows.map(row => { + if (row.url === action.data.url) { + hasMatch = true; + return Object.assign({}, row, {screenshot: action.data.screenshot}); + } + return row; + }); + return hasMatch ? Object.assign({}, prevState, {rows: newRows}) : prevState; + default: + return prevState; + } } -this.reducers = {TopSites}; +function Search(prevState = INITIAL_STATE.Search, action) { + switch (action.type) { + case at.SEARCH_STATE_UPDATED: { + if (!action.data) { + return prevState; + } + let {currentEngine, engines} = action.data; + return Object.assign({}, prevState, { + currentEngine, + engines + }); + } + default: + return prevState; + } +} +this.INITIAL_STATE = INITIAL_STATE; +this.reducers = {TopSites, Search}; this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE"]; diff --git a/browser/extensions/activity-stream/data/content/activity-stream.bundle.js b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js new file mode 100644 index 000000000000..e9c00838f8be --- /dev/null +++ b/browser/extensions/activity-stream/data/content/activity-stream.bundle.js @@ -0,0 +1,570 @@ +/******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) +/******/ return installedModules[moduleId].exports; +/******/ +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; +/******/ +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; +/******/ +/******/ // identity function for calling harmony imports with the correct context +/******/ __webpack_require__.i = function(value) { return value; }; +/******/ +/******/ // define getter function for harmony exports +/******/ __webpack_require__.d = function(exports, name, getter) { +/******/ if(!__webpack_require__.o(exports, name)) { +/******/ Object.defineProperty(exports, name, { +/******/ configurable: false, +/******/ enumerable: true, +/******/ get: getter +/******/ }); +/******/ } +/******/ }; +/******/ +/******/ // getDefaultExport function for compatibility with non-harmony modules +/******/ __webpack_require__.n = function(module) { +/******/ var getter = module && module.__esModule ? +/******/ function getDefault() { return module['default']; } : +/******/ function getModuleExports() { return module; }; +/******/ __webpack_require__.d(getter, 'a', getter); +/******/ return getter; +/******/ }; +/******/ +/******/ // Object.prototype.hasOwnProperty.call +/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; +/******/ +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; +/******/ +/******/ // Load entry module and return exports +/******/ return __webpack_require__(__webpack_require__.s = 10); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ (function(module, exports) { + +module.exports = React; + +/***/ }), +/* 1 */ +/***/ (function(module, exports) { + +module.exports = ReactRedux; + +/***/ }), +/* 2 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +const MAIN_MESSAGE_TYPE = "ActivityStream:Main"; +const CONTENT_MESSAGE_TYPE = "ActivityStream:Content"; + +const actionTypes = ["INIT", "UNINIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "PERFORM_SEARCH", "SCREENSHOT_UPDATED", "SEARCH_STATE_UPDATED", "TOP_SITES_UPDATED" +// The line below creates an object like this: +// { +// INIT: "INIT", +// UNINIT: "UNINIT" +// } +// It prevents accidentally adding a different key/value name. +].reduce((obj, type) => { + obj[type] = type;return obj; +}, {}); + +// Helper function for creating routed actions between content and main +// Not intended to be used by consumers +function _RouteMessage(action, options) { + const meta = action.meta ? Object.assign({}, action.meta) : {}; + if (!options || !options.from || !options.to) { + throw new Error("Routed Messages must have options as the second parameter, and must at least include a .from and .to property."); + } + // For each of these fields, if they are passed as an option, + // add them to the action. If they are not defined, remove them. + ["from", "to", "toTarget", "fromTarget", "skipOrigin"].forEach(o => { + if (typeof options[o] !== "undefined") { + meta[o] = options[o]; + } else if (meta[o]) { + delete meta[o]; + } + }); + return Object.assign({}, action, { meta }); +} + +/** + * SendToMain - Creates a message that will be sent to the Main process. + * + * @param {object} action Any redux action (required) + * @param {object} options + * @param {string} options.fromTarget The id of the content port from which the action originated. (optional) + * @return {object} An action with added .meta properties + */ +function SendToMain(action) { + let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + return _RouteMessage(action, { + from: CONTENT_MESSAGE_TYPE, + to: MAIN_MESSAGE_TYPE, + fromTarget: options.fromTarget + }); +} + +/** + * BroadcastToContent - Creates a message that will be sent to ALL content processes. + * + * @param {object} action Any redux action (required) + * @return {object} An action with added .meta properties + */ +function BroadcastToContent(action) { + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE + }); +} + +/** + * SendToContent - Creates a message that will be sent to a particular Content process. + * + * @param {object} action Any redux action (required) + * @param {string} target The id of a content port + * @return {object} An action with added .meta properties + */ +function SendToContent(action, target) { + if (!target) { + throw new Error("You must provide a target ID as the second parameter of SendToContent. If you want to send to all content processes, use BroadcastToContent"); + } + return _RouteMessage(action, { + from: MAIN_MESSAGE_TYPE, + to: CONTENT_MESSAGE_TYPE, + toTarget: target + }); +} + +var actionCreators = { + SendToMain, + SendToContent, + BroadcastToContent +}; + +// These are helpers to test for certain kinds of actions + +var actionUtils = { + isSendToMain(action) { + if (!action.meta) { + return false; + } + return action.meta.to === MAIN_MESSAGE_TYPE && action.meta.from === CONTENT_MESSAGE_TYPE; + }, + isBroadcastToContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && !action.meta.toTarget) { + return true; + } + return false; + }, + isSendToContent(action) { + if (!action.meta) { + return false; + } + if (action.meta.to === CONTENT_MESSAGE_TYPE && action.meta.toTarget) { + return true; + } + return false; + }, + _RouteMessage +}; +module.exports = { + actionTypes, + actionCreators, + actionUtils, + MAIN_MESSAGE_TYPE, + CONTENT_MESSAGE_TYPE +}; + +/***/ }), +/* 3 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const React = __webpack_require__(0); +const TopSites = __webpack_require__(8); +const Search = __webpack_require__(7); + +const Base = () => React.createElement( + "div", + { className: "outer-wrapper" }, + React.createElement( + "main", + null, + React.createElement(Search, null), + React.createElement(TopSites, null) + ) +); + +module.exports = Base; + +/***/ }), +/* 4 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +/* globals sendAsyncMessage, addMessageListener */ + +var _require = __webpack_require__(9); + +const createStore = _require.createStore, + combineReducers = _require.combineReducers, + applyMiddleware = _require.applyMiddleware; + +var _require2 = __webpack_require__(2); + +const au = _require2.actionUtils; + + +const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; +const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain"; +const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent"; + +/** + * A higher-order function which returns a reducer that, on MERGE_STORE action, + * will return the action.data object merged into the previous state. + * + * For all other actions, it merely calls mainReducer. + * + * Because we want this to merge the entire state object, it's written as a + * higher order function which takes the main reducer (itself often a call to + * combineReducers) as a parameter. + * + * @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION + * @return {function} a reducer that, on MERGE_STORE_ACTION action, + * will return the action.data object merged + * into the previous state, and the result + * of calling mainReducer otherwise. + */ +function mergeStateReducer(mainReducer) { + return (prevState, action) => { + if (action.type === MERGE_STORE_ACTION) { + return Object.assign({}, prevState, action.data); + } + + return mainReducer(prevState, action); + }; +} + +/** + * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary + */ +const messageMiddleware = store => next => action => { + if (au.isSendToMain(action)) { + sendAsyncMessage(OUTGOING_MESSAGE_NAME, action); + } + next(action); +}; + +/** + * initStore - Create a store and listen for incoming actions + * + * @param {object} reducers An object containing Redux reducers + * @return {object} A redux store + */ +module.exports = function initStore(reducers) { + const store = createStore(mergeStateReducer(combineReducers(reducers)), applyMiddleware(messageMiddleware)); + + addMessageListener(INCOMING_MESSAGE_NAME, msg => { + store.dispatch(msg.data); + }); + + return store; +}; + +module.exports.MERGE_STORE_ACTION = MERGE_STORE_ACTION; +module.exports.OUTGOING_MESSAGE_NAME = OUTGOING_MESSAGE_NAME; +module.exports.INCOMING_MESSAGE_NAME = INCOMING_MESSAGE_NAME; + +/***/ }), +/* 5 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + +var _require = __webpack_require__(2); + +const at = _require.actionTypes; + + +const INITIAL_STATE = { + TopSites: { + init: false, + rows: [] + }, + Search: { + currentEngine: { + name: "", + icon: "" + }, + engines: [] + } +}; + +// TODO: Handle some real actions here, once we have a TopSites feed working +function TopSites() { + let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.TopSites; + let action = arguments[1]; + + let hasMatch; + let newRows; + switch (action.type) { + case at.TOP_SITES_UPDATED: + if (!action.data) { + return prevState; + } + return Object.assign({}, prevState, { init: true, rows: action.data }); + case at.SCREENSHOT_UPDATED: + newRows = prevState.rows.map(row => { + if (row.url === action.data.url) { + hasMatch = true; + return Object.assign({}, row, { screenshot: action.data.screenshot }); + } + return row; + }); + return hasMatch ? Object.assign({}, prevState, { rows: newRows }) : prevState; + default: + return prevState; + } +} + +function Search() { + let prevState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : INITIAL_STATE.Search; + let action = arguments[1]; + + switch (action.type) { + case at.SEARCH_STATE_UPDATED: + { + if (!action.data) { + return prevState; + } + var _action$data = action.data; + let currentEngine = _action$data.currentEngine, + engines = _action$data.engines; + + return Object.assign({}, prevState, { + currentEngine, + engines + }); + } + default: + return prevState; + } +} +var reducers = { TopSites, Search }; +module.exports = { + reducers, + INITIAL_STATE +}; + +/***/ }), +/* 6 */ +/***/ (function(module, exports) { + +module.exports = ReactDOM; + +/***/ }), +/* 7 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const React = __webpack_require__(0); + +var _require = __webpack_require__(1); + +const connect = _require.connect; + +var _require2 = __webpack_require__(2); + +const actionTypes = _require2.actionTypes, + actionCreators = _require2.actionCreators; + + +const Search = React.createClass({ + displayName: "Search", + + getInitialState() { + return { searchString: "" }; + }, + performSearch(options) { + let searchData = { + engineName: options.engineName, + searchString: options.searchString, + searchPurpose: "newtab", + healthReportKey: "newtab" + }; + this.props.dispatch(actionCreators.SendToMain({ type: actionTypes.PERFORM_SEARCH, data: searchData })); + }, + onClick(event) { + const currentEngine = this.props.Search.currentEngine; + + event.preventDefault(); + this.performSearch({ engineName: currentEngine.name, searchString: this.state.searchString }); + }, + onChange(event) { + this.setState({ searchString: event.target.value }); + }, + render() { + return React.createElement( + "form", + { className: "search-wrapper" }, + React.createElement("span", { className: "search-label" }), + React.createElement("input", { value: this.state.searchString, type: "search", + onChange: this.onChange, + maxLength: "256", title: "Submit search", + placeholder: "Search the Web" }), + React.createElement("button", { onClick: this.onClick }) + ); + } +}); + +module.exports = connect(state => ({ Search: state.Search }))(Search); + +/***/ }), +/* 8 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const React = __webpack_require__(0); + +var _require = __webpack_require__(1); + +const connect = _require.connect; + + +function displayURL(url) { + return new URL(url).hostname.replace(/^www./, ""); +} + +const TopSites = props => React.createElement( + "section", + null, + React.createElement( + "h3", + { className: "section-title" }, + "Top Sites" + ), + React.createElement( + "ul", + { className: "top-sites-list" }, + props.TopSites.rows.map(link => { + const title = displayURL(link.url); + const className = `screenshot${link.screenshot ? " active" : ""}`; + const style = { backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none" }; + return React.createElement( + "li", + { key: link.url }, + React.createElement( + "a", + { href: link.url }, + React.createElement( + "div", + { className: "tile" }, + React.createElement( + "span", + { className: "letter-fallback", ariaHidden: true }, + title[0] + ), + React.createElement("div", { className: className, style: style }) + ), + React.createElement( + "div", + { className: "title" }, + title + ) + ) + ); + }) + ) +); + +module.exports = connect(state => ({ TopSites: state.TopSites }))(TopSites); + +/***/ }), +/* 9 */ +/***/ (function(module, exports) { + +module.exports = Redux; + +/***/ }), +/* 10 */ +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +/* globals addMessageListener, removeMessageListener */ +const React = __webpack_require__(0); +const ReactDOM = __webpack_require__(6); +const Base = __webpack_require__(3); + +var _require = __webpack_require__(1); + +const Provider = _require.Provider; + +const initStore = __webpack_require__(4); + +var _require2 = __webpack_require__(5); + +const reducers = _require2.reducers; + + +const store = initStore(reducers); + +ReactDOM.render(React.createElement( + Provider, + { store: store }, + React.createElement(Base, null) +), document.getElementById("root")); + +/***/ }) +/******/ ]); \ No newline at end of file diff --git a/browser/extensions/activity-stream/data/content/activity-stream.css b/browser/extensions/activity-stream/data/content/activity-stream.css new file mode 100644 index 000000000000..c1cad86f6191 --- /dev/null +++ b/browser/extensions/activity-stream/data/content/activity-stream.css @@ -0,0 +1,334 @@ +html { + box-sizing: border-box; } + +*, +*::before, +*::after { + box-sizing: inherit; } + +body { + margin: 0; } + +button, +input { + font-family: inherit; + font-size: inherit; } + +[hidden] { + display: none !important; } + +html, +body, +#root { + height: 100%; } + +body { + background: #F6F6F8; + color: #383E49; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif; + font-size: 16px; } + +h1, +h2 { + font-weight: normal; } + +a { + color: #00AFF7; + text-decoration: none; } + a:hover { + color: #2bc1ff; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; } + +.inner-border { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 3px; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 100; } + +@keyframes fadeIn { + from { + opacity: 0; } + to { + opacity: 1; } } + +.show-on-init { + opacity: 0; + transition: opacity 0.2s ease-in; } + .show-on-init.on { + opacity: 1; + animation: fadeIn 0.2s; } + +.actions { + border-top: solid 1px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: row; + margin: 0; + padding: 15px 25px; + justify-content: flex-start; } + .actions button { + background: #FBFBFB; + border: solid 1px #BFBFBF; + border-radius: 5px; + color: #858585; + cursor: pointer; + padding: 10px 30px; } + .actions button:hover { + box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1); + transition: box-shadow 150ms; } + .actions button.done { + background: #0695F9; + border: solid 1px #1677CF; + color: #FFF; + margin-inline-start: auto; } + +.outer-wrapper { + display: flex; + flex-grow: 1; + padding: 62px 32px 32px; + height: 100%; } + +main { + margin: auto; } + @media (min-width: 672px) { + main { + width: 608px; } } + @media (min-width: 800px) { + main { + width: 736px; } } + main section { + margin-bottom: 41px; } + +.section-title { + color: #6E707E; + font-size: 13px; + font-weight: bold; + text-transform: uppercase; + margin: 0 0 18px; } + +.top-sites-list { + list-style: none; + margin: 0; + padding: 0; + margin-inline-end: -32px; } + @media (min-width: 672px) { + .top-sites-list { + width: 640px; } } + @media (min-width: 800px) { + .top-sites-list { + width: 768px; } } + .top-sites-list li { + display: inline-block; + margin: 0 0 18px; + margin-inline-end: 32px; } + .top-sites-list a { + display: block; + color: inherit; } + .top-sites-list .tile { + position: relative; + height: 96px; + width: 96px; + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + color: #A0A0A0; + font-weight: 200; + font-size: 32px; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; } + .top-sites-list .tile:hover { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1); + transition: box-shadow 150ms; } + .top-sites-list .screenshot { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: #FFF; + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + background-size: 250%; + background-position: top left; + transition: opacity 1s; + opacity: 0; } + .top-sites-list .screenshot.active { + opacity: 1; } + .top-sites-list .title { + height: 30px; + line-height: 30px; + text-align: center; + white-space: nowrap; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + width: 96px; } + +.search-wrapper { + cursor: default; + display: flex; + position: relative; + margin: 0 0 48px; + width: 100%; + height: 36px; } + .search-wrapper .search-container { + z-index: 1001; + background: #FFF; + position: absolute; + left: 0; + right: 0; + top: 100%; + margin-top: -2px; + border: 1px solid #BFBFBF; + font-size: 12px; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1); + overflow: hidden; } + .search-wrapper .search-container .search-title { + color: #666; + padding: 5px 10px; + background-color: #F7F7F7; + display: flex; + align-items: center; + word-break: break-all; } + .search-wrapper .search-container .search-title p { + margin: 0; } + .search-wrapper .search-container .search-title #current-engine-icon { + margin-inline-end: 8px; } + .search-wrapper .search-container section { + border-bottom: 1px solid #EAEAEA; + margin-bottom: 0; } + .search-wrapper .search-container .search-suggestions ul { + padding: 0; + margin: 0; + list-style: none; } + .search-wrapper .search-container .search-suggestions ul li a { + cursor: default; + color: #000; + display: block; + padding: 4px 36px; } + .search-wrapper .search-container .search-suggestions ul li a:hover, .search-wrapper .search-container .search-suggestions ul li a.active { + background: #0996F8; + color: #FFF; } + .search-wrapper .search-container .history-search-suggestions { + border-bottom: 0; } + .search-wrapper .search-container .history-search-suggestions ul { + padding: 0; + margin: 0; + list-style: none; } + .search-wrapper .search-container .history-search-suggestions ul li a { + cursor: default; + color: #000; + display: block; + padding: 4px 10px; } + .search-wrapper .search-container .history-search-suggestions ul li a:hover, .search-wrapper .search-container .history-search-suggestions ul li a.active { + background: #0996F8; + color: #FFF; } + .search-wrapper .search-container .history-search-suggestions ul li a:hover > #historyIcon, + .search-wrapper .search-container .history-search-suggestions ul li a.active > #historyIcon { + background-image: url("assets/glyph-search-history.svg#search-history-active"); } + .search-wrapper .search-container .history-search-suggestions #historyIcon { + width: 16px; + height: 16px; + display: inline-block; + margin-inline-end: 10px; + margin-bottom: -3px; + background-image: url("assets/glyph-search-history.svg#search-history"); } + .search-wrapper .search-container .search-partners ul { + padding: 0; + margin: 0; + list-style: none; } + .search-wrapper .search-container .search-partners ul li { + display: inline-block; + padding: 5px 0; } + .search-wrapper .search-container .search-partners ul li a { + display: block; + padding: 3px 16px; + border-right: 1px solid #BFBFBF; } + .search-wrapper .search-container .search-partners ul li:hover, .search-wrapper .search-container .search-partners ul li.active { + background: #0996F8; + color: #FFF; } + .search-wrapper .search-container .search-partners ul li:hover a, .search-wrapper .search-container .search-partners ul li.active a { + border-color: transparent; } + .search-wrapper .search-container .search-settings button { + color: #666; + margin: 0; + padding: 0; + height: 32px; + text-align: center; + width: 100%; + border-style: solid none none; + border-radius: 0; + background: #F7F7F7; + border-top: 0; } + .search-wrapper .search-container .search-settings button:hover, .search-wrapper .search-container .search-settings button.active { + background: #EBEBEB; + box-shadow: none; } + .search-wrapper input { + border: 0; + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.1); + flex-grow: 1; + margin: 0; + outline: none; + padding: 0 12px 0 35px; + height: 100%; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + padding-inline-start: 35px; } + .search-wrapper input:focus { + border-color: #0996F8; + box-shadow: 0 0 0 2px #0996F8; + transition: box-shadow 150ms; + z-index: 1; } + .search-wrapper input:focus + button { + z-index: 1; + transition: box-shadow 150ms; + box-shadow: 0 0 0 2px #0996F8; + background-color: #0996F8; + background-image: url("assets/glyph-forward-16-white.svg"); + color: #FFF; } + .search-wrapper input:dir(rtl) { + border-radius: 0 4px 4px 0; } + .search-wrapper .search-label { + background: url("assets/glyph-search-16.svg") no-repeat center center/20px; + position: absolute; + top: 0; + offset-inline-start: 0; + height: 100%; + width: 35px; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; } + .search-wrapper button { + border-radius: 0 3px 3px 0; + margin-inline-start: -1px; + border: 0; + width: 36px; + padding: 0; + transition: box-shadow 150ms; + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.1); + background: #FFF url("assets/glyph-forward-16.svg") no-repeat center center; + background-size: 16px 16px; } + .search-wrapper button:hover { + z-index: 1; + transition: box-shadow 150ms; + box-shadow: 0 1px 0 0 rgba(0, 0, 1, 0.5); + background-color: #0996F8; + background-image: url("assets/glyph-forward-16-white.svg"); + color: #FFF; } + .search-wrapper button:dir(rtl) { + transform: scaleX(-1); } diff --git a/browser/extensions/activity-stream/data/content/activity-stream.html b/browser/extensions/activity-stream/data/content/activity-stream.html index ffc614d2245d..2cebfbea096a 100644 --- a/browser/extensions/activity-stream/data/content/activity-stream.html +++ b/browser/extensions/activity-stream/data/content/activity-stream.html @@ -3,29 +3,14 @@ New Tab + - -
-

New Tab

- -
- + +
+ + + + + diff --git a/browser/extensions/activity-stream/data/content/assets/glyph-forward-16-white.svg b/browser/extensions/activity-stream/data/content/assets/glyph-forward-16-white.svg new file mode 100644 index 000000000000..5bb5b8d535fe --- /dev/null +++ b/browser/extensions/activity-stream/data/content/assets/glyph-forward-16-white.svg @@ -0,0 +1,7 @@ + + Forward - 16 + + + + + diff --git a/browser/extensions/activity-stream/data/content/assets/glyph-forward-16.svg b/browser/extensions/activity-stream/data/content/assets/glyph-forward-16.svg new file mode 100644 index 000000000000..4fb2a7e5c9cd --- /dev/null +++ b/browser/extensions/activity-stream/data/content/assets/glyph-forward-16.svg @@ -0,0 +1,7 @@ + + Forward - 16 + + + + + diff --git a/browser/extensions/activity-stream/data/content/assets/glyph-search-16.svg b/browser/extensions/activity-stream/data/content/assets/glyph-search-16.svg new file mode 100644 index 000000000000..4d5eddbbe081 --- /dev/null +++ b/browser/extensions/activity-stream/data/content/assets/glyph-search-16.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/browser/extensions/activity-stream/data/content/assets/glyph-search-history.svg b/browser/extensions/activity-stream/data/content/assets/glyph-search-history.svg new file mode 100644 index 000000000000..1c04b429f791 --- /dev/null +++ b/browser/extensions/activity-stream/data/content/assets/glyph-search-history.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/browser/extensions/activity-stream/jar.mn b/browser/extensions/activity-stream/jar.mn index 186d65e4a4ed..8546dcb750f9 100644 --- a/browser/extensions/activity-stream/jar.mn +++ b/browser/extensions/activity-stream/jar.mn @@ -7,4 +7,8 @@ content/lib/ (./lib/*) content/common/ (./common/*) content/vendor/Redux.jsm (./vendor/Redux.jsm) + content/vendor/react.js (./vendor/react.js) + content/vendor/react-dom.js (./vendor/react-dom.js) + content/vendor/redux.js (./vendor/redux.js) + content/vendor/react-redux.js (./vendor/react-redux.js) content/data/ (./data/*) diff --git a/browser/extensions/activity-stream/lib/ActivityStream.jsm b/browser/extensions/activity-stream/lib/ActivityStream.jsm index ac0f92d9875b..18035bdf27b6 100644 --- a/browser/extensions/activity-stream/lib/ActivityStream.jsm +++ b/browser/extensions/activity-stream/lib/ActivityStream.jsm @@ -1,13 +1,35 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* globals XPCOMUtils, NewTabInit, TopSitesFeed, SearchFeed */ + "use strict"; const {utils: Cu} = Components; +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {}); +const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {}); // Feeds -const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm", {}); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabInit", + "resource://activity-stream/lib/NewTabInit.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TopSitesFeed", + "resource://activity-stream/lib/TopSitesFeed.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SearchFeed", + "resource://activity-stream/lib/SearchFeed.jsm"); + +const feeds = { + // When you add a feed here: + // 1. The key in this object should directly refer to a pref, not including the + // prefix (so "feeds.newtabinit" refers to the + // "browser.newtabpage.activity-stream.feeds.newtabinit" pref) + // 2. The value should be a function that returns a feed. + // 3. You should use XPCOMUtils.defineLazyModuleGetter to import the Feed, + // so it isn't loaded until the feed is enabled. + "feeds.newtabinit": () => new NewTabInit(), + "feeds.topsites": () => new TopSitesFeed(), + "feeds.search": () => new SearchFeed() +}; this.ActivityStream = class ActivityStream { @@ -23,14 +45,15 @@ this.ActivityStream = class ActivityStream { this.initialized = false; this.options = options; this.store = new Store(); + this.feeds = feeds; } init() { this.initialized = true; - this.store.init([ - new NewTabInit() - ]); + this.store.init(this.feeds); + this.store.dispatch({type: at.INIT}); } uninit() { + this.store.dispatch({type: at.UNINIT}); this.store.uninit(); this.initialized = false; } diff --git a/browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm b/browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm index 70cdc354fd32..c6ce0f7930ca 100644 --- a/browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm +++ b/browser/extensions/activity-stream/lib/ActivityStreamMessageChannel.jsm @@ -33,6 +33,7 @@ const DEFAULT_OPTIONS = { }; this.ActivityStreamMessageChannel = class ActivityStreamMessageChannel { + /** * ActivityStreamMessageChannel - This module connects a Redux store to a RemotePageManager in Firefox. * Call .createChannel to start the connection, and .destroyChannel to destroy it. @@ -183,13 +184,17 @@ this.ActivityStreamMessageChannel = class ActivityStreamMessageChannel { * @param {obj} msg.target A message target */ onMessage(msg) { - const action = msg.data; const {portID} = msg.target; - if (!action || !action.type) { + if (!msg.data || !msg.data.type) { Cu.reportError(new Error(`Received an improperly formatted message from ${portID}`)); return; } - this.onActionFromContent(action, msg.target.portID); + let action = {}; + Object.assign(action, msg.data); + // target is used to access a browser reference that came from the content + // and should only be used in feeds (not reducers) + action._target = msg.target; + this.onActionFromContent(action, portID); } } diff --git a/browser/extensions/activity-stream/lib/SearchFeed.jsm b/browser/extensions/activity-stream/lib/SearchFeed.jsm new file mode 100644 index 000000000000..992c07f69384 --- /dev/null +++ b/browser/extensions/activity-stream/lib/SearchFeed.jsm @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + /* globals ContentSearch, XPCOMUtils, Services */ +"use strict"; + +const {utils: Cu} = Components; +const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {}); +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch", + "resource:///modules/ContentSearch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +this.SearchFeed = class SearchFeed { + addObservers() { + Services.obs.addObserver(this, SEARCH_ENGINE_TOPIC); + } + removeObservers() { + Services.obs.removeObserver(this, SEARCH_ENGINE_TOPIC); + } + observe(subject, topic, data) { + switch (topic) { + case SEARCH_ENGINE_TOPIC: + if (data !== "engine-default") { + this.getState(); + } + break; + } + } + async getState() { + const state = await ContentSearch.currentStateObj(true); + const engines = state.engines.map(engine => ({ + name: engine.name, + icon: engine.iconBuffer + })); + const currentEngine = { + name: state.currentEngine.name, + icon: state.currentEngine.iconBuffer + }; + const action = {type: at.SEARCH_STATE_UPDATED, data: {engines, currentEngine}}; + this.store.dispatch(ac.BroadcastToContent(action)); + } + performSearch(browser, data) { + ContentSearch.performSearch({target: browser}, data); + } + onAction(action) { + switch (action.type) { + case at.INIT: + this.addObservers(); + this.getState(); + break; + case at.PERFORM_SEARCH: + this.performSearch(action._target.browser, action.data); + break; + case at.UNINIT: + this.removeObservers(); + break; + } + } +}; +this.EXPORTED_SYMBOLS = ["SearchFeed"]; diff --git a/browser/extensions/activity-stream/lib/Store.jsm b/browser/extensions/activity-stream/lib/Store.jsm index 196ce16df230..9bac91a0207c 100644 --- a/browser/extensions/activity-stream/lib/Store.jsm +++ b/browser/extensions/activity-stream/lib/Store.jsm @@ -1,15 +1,18 @@ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* global Preferences */ "use strict"; const {utils: Cu} = Components; const {redux} = Cu.import("resource://activity-stream/vendor/Redux.jsm", {}); -const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {}); const {reducers} = Cu.import("resource://activity-stream/common/Reducers.jsm", {}); const {ActivityStreamMessageChannel} = Cu.import("resource://activity-stream/lib/ActivityStreamMessageChannel.jsm", {}); +const PREF_PREFIX = "browser.newtabpage.activity-stream."; +Cu.import("resource://gre/modules/Preferences.jsm"); + /** * Store - This has a similar structure to a redux store, but includes some extra * functionality to allow for routing of actions between the Main processes @@ -32,7 +35,9 @@ this.Store = class Store { return this._store[method](...args); }.bind(this); }); - this.feeds = new Set(); + this.feeds = new Map(); + this._feedFactories = null; + this._prefHandlers = new Map(); this._messageChannel = new ActivityStreamMessageChannel({dispatch: this.dispatch}); this._store = redux.createStore( redux.combineReducers(reducers), @@ -53,33 +58,93 @@ this.Store = class Store { } /** - * init - Initializes the ActivityStreamMessageChannel channel, and adds feeds. - * After initialization has finished, an INIT action is dispatched. + * initFeed - Initializes a feed by calling its constructor function * - * @param {array} feeds An array of objects with an optional .onAction method + * @param {string} feedName The name of a feed, as defined in the object + * passed to Store.init */ - init(feeds) { - if (feeds) { - feeds.forEach(subscriber => { - subscriber.store = this; - this.feeds.add(subscriber); - }); - } - this._messageChannel.createChannel(); - this.dispatch({type: at.INIT}); + initFeed(feedName) { + const feed = this._feedFactories[feedName](); + feed.store = this; + this.feeds.set(feedName, feed); } /** - * uninit - Clears all feeds, dispatches an UNINIT action, and - * destroys the message manager channel. + * uninitFeed - Removes a feed and calls its uninit function if defined + * + * @param {string} feedName The name of a feed, as defined in the object + * passed to Store.init + */ + uninitFeed(feedName) { + const feed = this.feeds.get(feedName); + if (!feed) { + return; + } + if (feed.uninit) { + feed.uninit(); + } + this.feeds.delete(feedName); + } + + /** + * maybeStartFeedAndListenForPrefChanges - Listen for pref changes that turn a + * feed off/on, and as long as that pref was not explicitly set to + * false, initialize the feed immediately. + * + * @param {string} name The name of a feed, as defined in the object passed + * to Store.init + */ + maybeStartFeedAndListenForPrefChanges(name) { + const prefName = PREF_PREFIX + name; + + // If the pref was never set, set it to true by default. + if (!Preferences.has(prefName)) { + Preferences.set(prefName, true); + } + + // Create a listener that turns the feed off/on based on changes + // to the pref, and cache it so we can unlisten on shut-down. + const onPrefChanged = isEnabled => (isEnabled ? this.initFeed(name) : this.uninitFeed(name)); + this._prefHandlers.set(prefName, onPrefChanged); + Preferences.observe(prefName, onPrefChanged); + + // TODO: This should propbably be done in a generic pref manager for Activity Stream. + // If the pref is true, start the feed immediately. + if (Preferences.get(prefName)) { + this.initFeed(name); + } + } + + /** + * init - Initializes the ActivityStreamMessageChannel channel, and adds feeds. + * + * @param {array} feeds An array of objects with an optional .onAction method + */ + init(feedConstructors) { + if (feedConstructors) { + this._feedFactories = feedConstructors; + for (const name of Object.keys(feedConstructors)) { + this.maybeStartFeedAndListenForPrefChanges(name); + } + } + this._messageChannel.createChannel(); + } + + /** + * uninit - Uninitalizes each feed, clears them, and destroys the message + * manager channel. * * @return {type} description */ uninit() { + this.feeds.forEach(feed => this.uninitFeed(feed)); + this._prefHandlers.forEach((handler, pref) => Preferences.ignore(pref, handler)); + this._prefHandlers.clear(); + this._feedFactories = null; this.feeds.clear(); - this.dispatch({type: at.UNINIT}); this._messageChannel.destroyChannel(); } }; -this.EXPORTED_SYMBOLS = ["Store"]; +this.PREF_PREFIX = PREF_PREFIX; +this.EXPORTED_SYMBOLS = ["Store", "PREF_PREFIX"]; diff --git a/browser/extensions/activity-stream/lib/TopSitesFeed.jsm b/browser/extensions/activity-stream/lib/TopSitesFeed.jsm new file mode 100644 index 000000000000..14fc41dd5858 --- /dev/null +++ b/browser/extensions/activity-stream/lib/TopSitesFeed.jsm @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + /* globals PlacesProvider, PreviewProvider */ +"use strict"; + +const {utils: Cu} = Components; +const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {}); + +Cu.import("resource:///modules/PlacesProvider.jsm"); +Cu.import("resource:///modules/PreviewProvider.jsm"); + +const TOP_SITES_SHOWMORE_LENGTH = 12; +const UPDATE_TIME = 15 * 60 * 1000; // 15 minutes +const DEFAULT_TOP_SITES = [ + {"url": "https://www.facebook.com/"}, + {"url": "https://www.youtube.com/"}, + {"url": "http://www.amazon.com/"}, + {"url": "https://www.yahoo.com/"}, + {"url": "http://www.ebay.com"}, + {"url": "https://twitter.com/"} +].map(row => Object.assign(row, {isDefault: true})); + +this.TopSitesFeed = class TopSitesFeed { + constructor() { + this.lastUpdated = 0; + } + async getScreenshot(url) { + let screenshot = await PreviewProvider.getThumbnail(url); + const action = {type: at.SCREENSHOT_UPDATED, data: {url, screenshot}}; + this.store.dispatch(ac.BroadcastToContent(action)); + } + async getLinksWithDefaults(action) { + let links = await PlacesProvider.links.getLinks(); + + if (!links) { + links = []; + } else { + links = links.filter(link => link && link.type !== "affiliate").slice(0, 12); + } + + if (links.length < TOP_SITES_SHOWMORE_LENGTH) { + links = [...links, ...DEFAULT_TOP_SITES].slice(0, TOP_SITES_SHOWMORE_LENGTH); + } + + return links; + } + async refresh(action) { + const links = await this.getLinksWithDefaults(); + const newAction = {type: at.TOP_SITES_UPDATED, data: links}; + + // Send an update to content so the preloaded tab can get the updated content + this.store.dispatch(ac.SendToContent(newAction, action.meta.fromTarget)); + this.lastUpdated = Date.now(); + + // Now, get a screenshot for every item + for (let link of links) { + this.getScreenshot(link.url); + } + } + onAction(action) { + let realRows; + switch (action.type) { + case at.NEW_TAB_LOAD: + // Only check against real rows returned from history, not default ones. + realRows = this.store.getState().TopSites.rows.filter(row => !row.isDefault); + // When a new tab is opened, if we don't have enough top sites yet, refresh the data. + if (realRows.length < TOP_SITES_SHOWMORE_LENGTH) { + this.refresh(action); + } else if (Date.now() - this.lastUpdated >= UPDATE_TIME) { + // When a new tab is opened, if the last time we refreshed the data + // is greater than 15 minutes, refresh the data. + this.refresh(action); + } + break; + } + } +}; + +this.UPDATE_TIME = UPDATE_TIME; +this.TOP_SITES_SHOWMORE_LENGTH = TOP_SITES_SHOWMORE_LENGTH; +this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES; +this.EXPORTED_SYMBOLS = ["TopSitesFeed", "UPDATE_TIME", "DEFAULT_TOP_SITES", "TOP_SITES_SHOWMORE_LENGTH"]; diff --git a/browser/extensions/activity-stream/test/.eslintrc.js b/browser/extensions/activity-stream/test/.eslintrc.js new file mode 100644 index 000000000000..438e41b01a3c --- /dev/null +++ b/browser/extensions/activity-stream/test/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + "env": { + "node": true, + "es6": true, + "mocha": true + }, + "globals": { + "assert": true, + "sinon": true + } +}; diff --git a/browser/extensions/activity-stream/test/functional/mochitest/.eslintrc.js b/browser/extensions/activity-stream/test/functional/mochitest/.eslintrc.js new file mode 100644 index 000000000000..b1148a7004e8 --- /dev/null +++ b/browser/extensions/activity-stream/test/functional/mochitest/.eslintrc.js @@ -0,0 +1,42 @@ +module.exports = { + "globals": { + "add_task": false, + "Assert": false, + "BrowserOpenTab": false, + "BrowserTestUtils": false, + "content": false, + "ContentTask": false, + "ContentTaskUtils": false, + "Components": false, + "EventUtils": false, + "executeSoon": false, + "expectUncaughtException": false, + "export_assertions": false, + "extractJarToTmp": false, + "finish": false, + "getJar": false, + "getRootDirectory": false, + "getTestFilePath": false, + "gBrowser": false, + "gTestPath": false, + "info": false, + "is": false, + "isnot": false, + "ok": false, + "OpenBrowserWindow": false, + "Preferences": false, + "registerCleanupFunction": false, + "requestLongerTimeout": false, + "Services": false, + "SimpleTest": false, + "SpecialPowers": false, + "TestUtils": false, + "thisTestLeaksUncaughtRejectionsAndShouldBeFixed": false, + "todo": false, + "todo_is": false, + "todo_isnot": false, + "waitForClipboard": false, + "waitForExplicitFinish": false, + "waitForFocus": false + } +}; diff --git a/browser/extensions/activity-stream/test/functional/mochitest/browser.ini b/browser/extensions/activity-stream/test/functional/mochitest/browser.ini new file mode 100644 index 000000000000..65e4d43db9c8 --- /dev/null +++ b/browser/extensions/activity-stream/test/functional/mochitest/browser.ini @@ -0,0 +1,13 @@ +[DEFAULT] +# XXX This defaults to forcing activity-stream tests to be skipped in m-c, +# since, as of this writing, mozilla-central itself is still turned off. +# The tests can be run locally using 'npm run mochitest' which does various +# overrides. +skip-if=!activity_stream + +[browser_dummy_test.js] +skip-if=true +# XXX The above test is required because having only one test causes +# The default skip-if to silently fail. As soon as we add another test here, +# we should get rid of it, and the following line. +[browser_as_load_location.js] diff --git a/browser/extensions/activity-stream/test/functional/mochitest/browser_as_load_location.js b/browser/extensions/activity-stream/test/functional/mochitest/browser_as_load_location.js new file mode 100644 index 000000000000..7106aa624fc6 --- /dev/null +++ b/browser/extensions/activity-stream/test/functional/mochitest/browser_as_load_location.js @@ -0,0 +1,34 @@ +"use strict"; + +let Cu = Components.utils; +Cu.import("resource://gre/modules/Services.jsm"); + +/** + * Tests that opening a new tab opens a page with the expected activity stream + * content. + * + * XXX /browser/components/newtab/tests/browser/browser_newtab_overrides in + * mozilla-central is where this test was adapted from. Once we get decide on + * and implement how we're going to set the URL in mozilla-central, we may well + * want to (separately from this test), clone/adapt that entire file for our + * new setup. + */ +add_task(async function checkActivityStreamLoads() { + const asURL = "resource://activity-stream/data/content/activity-stream.html"; + + // simulate a newtab open as a user would + BrowserOpenTab(); + + // wait until the browser loads + let browser = gBrowser.selectedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + // check what the content task thinks has been loaded. + await ContentTask.spawn(browser, {url: asURL}, args => { + Assert.ok(content.document.querySelector("body.activity-stream"), + 'Got { + Assert.ok(content.document.querySelector("body.activity-stream"), + 'Got { + 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("SendToMain", () => { + it("should create the right action", () => { + const action = {type: "FOO", data: "BAR"}; + const newAction = ac.SendToMain(action); + assert.deepEqual(newAction, { + type: "FOO", + data: "BAR", + meta: {from: CONTENT_MESSAGE_TYPE, to: MAIN_MESSAGE_TYPE} + }); + }); + describe("isSendToMain", () => { + it("should return true if action is SendToMain", () => { + const newAction = ac.SendToMain({type: "FOO"}); + assert.isTrue(au.isSendToMain(newAction)); + }); + it("should return false if action is not SendToMain", () => { + assert.isFalse(au.isSendToMain({type: "FOO"})); + }); + }); + }); + describe("SendToContent", () => { + it("should create the right action", () => { + const action = {type: "FOO", data: "BAR"}; + const targetId = "abc123"; + const newAction = ac.SendToContent(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.SendToContent({type: "FOO"}); + }); + }); + describe("isSendToContent", () => { + it("should return true if action is SendToContent", () => { + const newAction = ac.SendToContent({type: "FOO"}, "foo123"); + assert.isTrue(au.isSendToContent(newAction)); + }); + it("should return false if action is not SendToMain", () => { + assert.isFalse(au.isSendToContent({type: "FOO"})); + assert.isFalse(au.isSendToContent(ac.BroadcastToContent({type: "FOO"}))); + }); + }); + }); + 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.SendToContent({type: "FOO"}, "foo123"))); + }); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/common/Reducers.test.js b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js new file mode 100644 index 000000000000..29e995a4e8ce --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/common/Reducers.test.js @@ -0,0 +1,51 @@ +const {reducers, INITIAL_STATE} = require("common/Reducers.jsm"); +const {TopSites, Search} = reducers; +const {actionTypes: at} = require("common/Actions.jsm"); + +describe("Reducers", () => { + 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: 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 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); + }); + }); + describe("Search", () => { + it("should return the initial state", () => { + const nextState = Search(undefined, {type: "FOO"}); + assert.equal(nextState, INITIAL_STATE.Search); + }); + it("should not update state for empty action.data on Search", () => { + const nextState = Search(undefined, {type: at.SEARCH_STATE_UPDATED}); + assert.equal(nextState, INITIAL_STATE.Search); + }); + it("should update the current engine and the engines on SEARCH_STATE_UPDATED", () => { + const newEngine = {name: "Google", iconBuffer: "icon.ico"}; + const nextState = Search(undefined, {type: at.SEARCH_STATE_UPDATED, data: {currentEngine: newEngine, engines: [newEngine]}}); + assert.equal(nextState.currentEngine.name, newEngine.name); + assert.equal(nextState.currentEngine.icon, newEngine.icon); + assert.equal(nextState.engines[0].name, newEngine.name); + assert.equal(nextState.engines[0].icon, newEngine.icon); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js b/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js new file mode 100644 index 000000000000..7a3bada159e8 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/ActivityStream.test.js @@ -0,0 +1,70 @@ +const injector = require("inject!lib/ActivityStream.jsm"); + +describe("ActivityStream", () => { + let sandbox; + let as; + let ActivityStream; + function NewTabInit() {} + function TopSitesFeed() {} + function SearchFeed() {} + before(() => { + sandbox = sinon.sandbox.create(); + ({ActivityStream} = injector({ + "lib/NewTabInit.jsm": {NewTabInit}, + "lib/TopSitesFeed.jsm": {TopSitesFeed}, + "lib/SearchFeed.jsm": {SearchFeed} + })); + }); + + afterEach(() => sandbox.restore()); + + beforeEach(() => { + as = new ActivityStream(); + sandbox.stub(as.store, "init"); + sandbox.stub(as.store, "uninit"); + }); + + it("should exist", () => { + assert.ok(ActivityStream); + }); + it("should initialize with .initialized=false", () => { + assert.isFalse(as.initialized, ".initialized"); + }); + describe("#init", () => { + beforeEach(() => { + as.init(); + }); + it("should set .initialized to true", () => { + assert.isTrue(as.initialized, ".initialized"); + }); + it("should call .store.init", () => { + assert.calledOnce(as.store.init); + }); + }); + 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); + }); + }); + describe("feeds", () => { + it("should create a NewTabInit feed", () => { + const feed = as.feeds["feeds.newtabinit"](); + assert.instanceOf(feed, NewTabInit); + }); + it("should create a TopSites feed", () => { + const feed = as.feeds["feeds.topsites"](); + assert.instanceOf(feed, TopSitesFeed); + }); + it("should create a Search feed", () => { + const feed = as.feeds["feeds.search"](); + assert.instanceOf(feed, SearchFeed); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/ActivityStreamMessageChannel.test.js b/browser/extensions/activity-stream/test/unit/lib/ActivityStreamMessageChannel.test.js new file mode 100644 index 000000000000..a5a48f74fc5c --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/ActivityStreamMessageChannel.test.js @@ -0,0 +1,235 @@ +const {ActivityStreamMessageChannel, DEFAULT_OPTIONS} = require("lib/ActivityStreamMessageChannel.jsm"); +const {addNumberReducer, GlobalOverrider} = require("test/unit/utils"); +const {createStore, applyMiddleware} = require("redux"); +const {actionTypes: at, actionCreators: ac} = require("common/Actions.jsm"); + +const OPTIONS = ["pageURL, outgoingMessageName", "incomingMessageName", "dispatch"]; + +describe("ActivityStreamMessageChannel", () => { + let globals; + let dispatch; + let mm; + before(() => { + function RP(url) { + this.url = url; + this.messagePorts = []; + this.addMessageListener = globals.sandbox.spy(); + this.sendAsyncMessage = globals.sandbox.spy(); + this.destroy = globals.sandbox.spy(); + } + globals = new GlobalOverrider(); + globals.set("AboutNewTab", { + override: globals.sandbox.spy(), + reset: globals.sandbox.spy() + }); + globals.set("RemotePages", RP); + dispatch = globals.sandbox.spy(); + }); + beforeEach(() => { + mm = new ActivityStreamMessageChannel({dispatch}); + }); + + afterEach(() => globals.reset()); + after(() => globals.restore()); + + 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("#createChannel", () => { + it("should create .channel with the correct URL", () => { + mm.createChannel(); + assert.ok(mm.channel); + assert.equal(mm.channel.url, mm.pageURL); + }); + it("should add 3 message listeners", () => { + mm.createChannel(); + assert.callCount(mm.channel.addMessageListener, 3); + }); + it("should add the custom message listener to the channel", () => { + mm.createChannel(); + assert.calledWith(mm.channel.addMessageListener, mm.incomingMessageName, mm.onMessage); + }); + it("should override AboutNewTab", () => { + mm.createChannel(); + assert.calledOnce(global.AboutNewTab.override); + }); + it("should not override AboutNewTab if the pageURL is not about:newtab", () => { + mm = new ActivityStreamMessageChannel({pageURL: "foo.html"}); + mm.createChannel(); + assert.notCalled(global.AboutNewTab.override); + }); + }); + describe("#destroyChannel", () => { + let channel; + beforeEach(() => { + mm.createChannel(); + channel = mm.channel; + }); + it("should call channel.destroy()", () => { + mm.destroyChannel(); + assert.calledOnce(channel.destroy); + }); + it("should set .channel to null", () => { + mm.destroyChannel(); + assert.isNull(mm.channel); + }); + it("should reset AboutNewTab", () => { + mm.destroyChannel(); + assert.calledOnce(global.AboutNewTab.reset); + }); + it("should not reset AboutNewTab if the pageURL is not about:newtab", () => { + mm = new ActivityStreamMessageChannel({pageURL: "foo.html"}); + mm.createChannel(); + mm.destroyChannel(); + assert.notCalled(global.AboutNewTab.reset); + }); + }); + }); + describe("Message handling", () => { + describe("#getTargetById", () => { + it("should get an id if it exists", () => { + const t = {portID: "foo"}; + mm.createChannel(); + mm.channel.messagePorts.push(t); + assert.equal(mm.getTargetById("foo"), t); + }); + it("should return null if the target doesn't exist", () => { + const t = {portID: "foo"}; + mm.createChannel(); + mm.channel.messagePorts.push(t); + assert.equal(mm.getTargetById("bar"), null); + }); + }); + describe("#onNewTabLoad", () => { + it("should dispatch a NEW_TAB_LOAD action", () => { + const t = {portID: "foo"}; + sinon.stub(mm, "onActionFromContent"); + mm.onNewTabLoad({target: t}); + assert.calledWith(mm.onActionFromContent, {type: at.NEW_TAB_LOAD}, "foo"); + }); + }); + describe("#onNewTabUnload", () => { + it("should dispatch a NEW_TAB_UNLOAD action", () => { + const t = {portID: "foo"}; + sinon.stub(mm, "onActionFromContent"); + mm.onNewTabUnload({target: t}); + assert.calledWith(mm.onActionFromContent, {type: at.NEW_TAB_UNLOAD}, "foo"); + }); + }); + describe("#onMessage", () => { + it("should report an error if the msg.data is missing", () => { + mm.onMessage({target: {portID: "foo"}}); + assert.calledOnce(global.Components.utils.reportError); + }); + it("should report an error if the msg.data.type is missing", () => { + mm.onMessage({target: {portID: "foo"}, data: "foo"}); + assert.calledOnce(global.Components.utils.reportError); + }); + it("should call onActionFromContent", () => { + sinon.stub(mm, "onActionFromContent"); + const action = {data: {data: {}, type: "FOO"}, target: {portID: "foo"}}; + const expectedAction = { + type: action.data.type, + data: action.data.data, + _target: {portID: "foo"} + }; + mm.onMessage(action); + assert.calledWith(mm.onActionFromContent, expectedAction, "foo"); + }); + }); + }); + describe("Sending and broadcasting", () => { + describe("#send", () => { + it("should send a message on the right port", () => { + const t = {portID: "foo", sendAsyncMessage: sinon.spy()}; + mm.createChannel(); + mm.channel.messagePorts = [t]; + const action = ac.SendToContent({type: "HELLO"}, "foo"); + mm.send(action, "foo"); + assert.calledWith(t.sendAsyncMessage, DEFAULT_OPTIONS.outgoingMessageName, action); + }); + it("should not throw if the target isn't around", () => { + mm.createChannel(); + // port is not added to the channel + const action = ac.SendToContent({type: "HELLO"}, "foo"); + + assert.doesNotThrow(() => mm.send(action, "foo")); + }); + }); + describe("#broadcast", () => { + it("should send a message on the channel", () => { + mm.createChannel(); + const action = ac.BroadcastToContent({type: "HELLO"}); + mm.broadcast(action); + assert.calledWith(mm.channel.sendAsyncMessage, DEFAULT_OPTIONS.outgoingMessageName, action); + }); + }); + }); + describe("Handling actions", () => { + describe("#onActionFromContent", () => { + beforeEach(() => mm.onActionFromContent({type: "FOO"}, "foo")); + it("should dispatch a SendToMain action", () => { + assert.calledOnce(dispatch); + const action = dispatch.firstCall.args[0]; + assert.equal(action.type, "FOO", "action.type"); + }); + it("should have the right fromTarget", () => { + const action = dispatch.firstCall.args[0]; + assert.equal(action.meta.fromTarget, "foo", "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 if the action is SendToContent", () => { + sinon.stub(mm, "send"); + const action = ac.SendToContent({type: "FOO"}, "foo"); + + mm.createChannel(); + store.dispatch(action); + + assert.calledWith(mm.send, action); + }); + it("should call .broadcast if the action is BroadcastToContent", () => { + sinon.stub(mm, "broadcast"); + const action = ac.BroadcastToContent({type: "FOO"}); + + mm.createChannel(); + store.dispatch(action); + + assert.calledWith(mm.broadcast, action); + }); + it("should dispatch other actions normally", () => { + sinon.stub(mm, "send"); + sinon.stub(mm, "broadcast"); + + mm.createChannel(); + store.dispatch({type: "ADD", data: 1}); + + assert.equal(store.getState(), 1); + assert.notCalled(mm.send); + assert.notCalled(mm.broadcast); + }); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/SearchFeed.test.js b/browser/extensions/activity-stream/test/unit/lib/SearchFeed.test.js new file mode 100644 index 000000000000..d1e710cbb221 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/SearchFeed.test.js @@ -0,0 +1,77 @@ +"use strict"; +const {SearchFeed} = require("lib/SearchFeed.jsm"); +const {GlobalOverrider} = require("test/unit/utils"); +const {actionTypes: at} = require("common/Actions.jsm"); +const fakeEngines = [{name: "Google", iconBuffer: "icon.ico"}]; +describe("Search Feed", () => { + let feed; + let globals; + before(() => { + globals = new GlobalOverrider(); + globals.set("ContentSearch", { + currentStateObj: globals.sandbox.spy(() => Promise.resolve({engines: fakeEngines, currentEngine: {}})), + performSearch: globals.sandbox.spy((browser, searchData) => Promise.resolve({browser, searchData})) + }); + }); + beforeEach(() => { + feed = new SearchFeed(); + feed.store = {dispatch: sinon.spy()}; + }); + afterEach(() => globals.reset()); + after(() => globals.restore()); + + it("should call get state (with true) from the content search provider on INIT", () => { + feed.onAction({type: at.INIT}); + // calling currentStateObj with 'true' allows us to return a data uri for the + // icon, instead of an array buffer + assert.calledWith(global.ContentSearch.currentStateObj, true); + }); + it("should get the the state on INIT", () => { + sinon.stub(feed, "getState"); + feed.onAction({type: at.INIT}); + assert.calledOnce(feed.getState); + }); + it("should add observers on INIT", () => { + sinon.stub(feed, "addObservers"); + feed.onAction({type: at.INIT}); + assert.calledOnce(feed.addObservers); + }); + it("should remove observers on UNINIT", () => { + sinon.stub(feed, "removeObservers"); + feed.onAction({type: at.UNINIT}); + assert.calledOnce(feed.removeObservers); + }); + it("should call services.obs.addObserver on INIT", () => { + feed.onAction({type: at.INIT}); + assert.calledOnce(global.Services.obs.addObserver); + }); + it("should call services.obs.removeObserver on UNINIT", () => { + feed.onAction({type: at.UNINIT}); + assert.calledOnce(global.Services.obs.removeObserver); + }); + it("should dispatch one event with the state", () => ( + feed.getState().then(() => { + assert.calledOnce(feed.store.dispatch); + }) + )); + it("should perform a search on PERFORM_SEARCH", () => { + sinon.stub(feed, "performSearch"); + feed.onAction({_target: {browser: {}}, type: at.PERFORM_SEARCH}); + assert.calledOnce(feed.performSearch); + }); + it("should call performSearch with an action", () => { + const action = {_target: {browser: "browser"}, data: {searchString: "hello"}}; + feed.performSearch(action._target.browser, action.data); + assert.calledWith(global.ContentSearch.performSearch, {target: action._target.browser}, action.data); + }); + it("should get the state if we change the search engines", () => { + sinon.stub(feed, "getState"); + feed.observe(null, "browser-search-engine-modified", "engine-current"); + assert.calledOnce(feed.getState); + }); + it("shouldn't get the state if it's not the right notification", () => { + sinon.stub(feed, "getState"); + feed.observe(null, "some-other-notification", "engine-current"); + assert.notCalled(feed.getState); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/Store.test.js b/browser/extensions/activity-stream/test/unit/lib/Store.test.js new file mode 100644 index 000000000000..d49aa3dececa --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/Store.test.js @@ -0,0 +1,210 @@ +const injector = require("inject!lib/Store.jsm"); +const {createStore} = require("redux"); +const {addNumberReducer} = require("test/unit/utils"); +const {GlobalOverrider} = require("test/unit/utils"); +describe("Store", () => { + let Store; + let Preferences; + let sandbox; + let store; + let globals; + let PREF_PREFIX; + beforeEach(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + Preferences = new Map(); + Preferences.observe = sandbox.spy(); + Preferences.ignore = sandbox.spy(); + globals.set("Preferences", Preferences); + function ActivityStreamMessageChannel(options) { + this.dispatch = options.dispatch; + this.createChannel = sandbox.spy(); + this.destroyChannel = sandbox.spy(); + this.middleware = sandbox.spy(s => next => action => next(action)); + } + ({Store, PREF_PREFIX} = injector({"lib/ActivityStreamMessageChannel.jsm": {ActivityStreamMessageChannel}})); + store = new Store(); + }); + afterEach(() => { + Preferences.clear(); + globals.restore(); + }); + it("should have an .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._messageChannel); + assert.equal(store._messageChannel.dispatch, store.dispatch); + }); + 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 {} + Preferences.set(`${PREF_PREFIX}foo`, false); + store.init({foo: () => new Foo()}); + store.initFeed("foo"); + + assert.isTrue(store.feeds.has("foo"), "foo is set"); + assert.instanceOf(store.feeds.get("foo"), Foo); + }); + it("should add a .store property to the feed", () => { + class Foo {} + store._feedFactories = {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 uninit function if it is defined", () => { + let feed; + function createFeed() { + feed = {uninit: sinon.spy()}; + return feed; + } + store._feedFactories = {foo: createFeed}; + + store.initFeed("foo"); + store.uninitFeed("foo"); + + assert.calledOnce(feed.uninit); + }); + it("should remove the feed from .feeds", () => { + class Foo {} + store._feedFactories = {foo: () => new Foo()}; + + store.initFeed("foo"); + store.uninitFeed("foo"); + + assert.isFalse(store.feeds.has("foo"), "foo is not in .feeds"); + }); + }); + describe("maybeStartFeedAndListenForPrefChanges", () => { + beforeEach(() => { + sinon.stub(store, "initFeed"); + sinon.stub(store, "uninitFeed"); + }); + it("should set the new pref in Preferences to true, if it was never defined", () => { + store.maybeStartFeedAndListenForPrefChanges("foo"); + assert.isTrue(Preferences.get(`${PREF_PREFIX}foo`)); + }); + it("should not override the pref if it was already set", () => { + Preferences.set(`${PREF_PREFIX}foo`, false); + store.maybeStartFeedAndListenForPrefChanges("foo"); + assert.isFalse(Preferences.get(`${PREF_PREFIX}foo`)); + }); + it("should initialize the feed if the Pref is set to true", () => { + Preferences.set(`${PREF_PREFIX}foo`, true); + store.maybeStartFeedAndListenForPrefChanges("foo"); + assert.calledWith(store.initFeed, "foo"); + }); + it("should not initialize the feed if the Pref is set to false", () => { + Preferences.set(`${PREF_PREFIX}foo`, false); + store.maybeStartFeedAndListenForPrefChanges("foo"); + assert.notCalled(store.initFeed); + }); + it("should observe the pref", () => { + store.maybeStartFeedAndListenForPrefChanges("foo"); + assert.calledWith(Preferences.observe, `${PREF_PREFIX}foo`, store._prefHandlers.get(`${PREF_PREFIX}foo`)); + }); + describe("handler", () => { + let handler; + beforeEach(() => { + store.maybeStartFeedAndListenForPrefChanges("foo"); + handler = store._prefHandlers.get(`${PREF_PREFIX}foo`); + }); + it("should initialize the feed if called with true", () => { + handler(true); + assert.calledWith(store.initFeed, "foo"); + }); + it("should uninitialize the feed if called with false", () => { + handler(false); + assert.calledWith(store.uninitFeed, "foo"); + }); + }); + }); + describe("#init", () => { + it("should call .maybeStartFeedAndListenForPrefChanges with each key", () => { + sinon.stub(store, "maybeStartFeedAndListenForPrefChanges"); + store.init({foo: () => {}, bar: () => {}}); + assert.calledWith(store.maybeStartFeedAndListenForPrefChanges, "foo"); + assert.calledWith(store.maybeStartFeedAndListenForPrefChanges, "bar"); + }); + it("should initialize the ActivityStreamMessageChannel channel", () => { + store.init(); + assert.calledOnce(store._messageChannel.createChannel); + }); + }); + describe("#uninit", () => { + it("should clear .feeds, ._prefHandlers, and ._feedFactories", () => { + store.init({ + a: () => ({}), + b: () => ({}), + c: () => ({}) + }); + + store.uninit(); + + assert.equal(store.feeds.size, 0); + assert.equal(store._prefHandlers.size, 0); + assert.isNull(store._feedFactories); + }); + it("should destroy the ActivityStreamMessageChannel channel", () => { + store.uninit(); + assert.calledOnce(store._messageChannel.destroyChannel); + }); + }); + 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", () => { + const {dispatch} = store; + const sub = {onAction: sinon.spy()}; + const action = {type: "FOO"}; + + store.init({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/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js b/browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js new file mode 100644 index 000000000000..7e012f9e82c1 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/TelemetrySender.test.js @@ -0,0 +1,271 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +const {GlobalOverrider, FakePrefs} = require("test/unit/utils"); +const {TelemetrySender} = require("lib/TelemetrySender.jsm"); + +/** + * A reference to the fake preferences object created by the TelemetrySender + * constructor so that we can use the API. + */ +let fakePrefs; +const prefInitHook = function() { + fakePrefs = this; // eslint-disable-line consistent-this +}; +const tsArgs = {prefInitHook}; + +describe("TelemetrySender", () => { + let globals; + let tSender; + let fetchStub; + const observerTopics = ["user-action-event", "performance-event", + "tab-session-complete", "undesired-event"]; + const fakeEndpointUrl = "http://127.0.0.1/stuff"; + const fakePingJSON = JSON.stringify({action: "fake_action", monkey: 1}); + const fakeFetchHttpErrorResponse = {ok: false, status: 400}; + const fakeFetchSuccessResponse = {ok: true, status: 200}; + + function assertNotificationObserversAdded() { + observerTopics.forEach(topic => { + assert.calledWithExactly( + global.Services.obs.addObserver, tSender, topic, true); + }); + } + + function assertNotificationObserversRemoved() { + observerTopics.forEach(topic => { + assert.calledWithExactly( + global.Services.obs.removeObserver, tSender, topic); + }); + } + + before(() => { + globals = new GlobalOverrider(); + + fetchStub = globals.sandbox.stub(); + + globals.set("Preferences", FakePrefs); + globals.set("fetch", fetchStub); + }); + + beforeEach(() => { + }); + + afterEach(() => { + globals.reset(); + FakePrefs.prototype.prefs = {}; + }); + + after(() => globals.restore()); + + it("should construct the Prefs object with the right branch", () => { + globals.sandbox.spy(global, "Preferences"); + + tSender = new TelemetrySender(tsArgs); + + assert.calledOnce(global.Preferences); + assert.calledWith(global.Preferences, + sinon.match.has("branch", "browser.newtabpage.activity-stream")); + }); + + it("should set the enabled prop to false if the pref is false", () => { + FakePrefs.prototype.prefs = {telemetry: false}; + + tSender = new TelemetrySender(tsArgs); + + assert.isFalse(tSender.enabled); + }); + + it("should not add notification observers if the enabled pref is false", () => { + FakePrefs.prototype.prefs = {telemetry: false}; + + tSender = new TelemetrySender(tsArgs); + + assert.notCalled(global.Services.obs.addObserver); + }); + + it("should set the enabled prop to true if the pref is true", () => { + FakePrefs.prototype.prefs = {telemetry: true}; + + tSender = new TelemetrySender(tsArgs); + + assert.isTrue(tSender.enabled); + }); + + it("should add all notification observers if the enabled pref is true", () => { + FakePrefs.prototype.prefs = {telemetry: true}; + + tSender = new TelemetrySender(tsArgs); + + assertNotificationObserversAdded(); + }); + + describe("#_sendPing()", () => { + beforeEach(() => { + FakePrefs.prototype.prefs = { + "telemetry": true, + "telemetry.ping.endpoint": fakeEndpointUrl + }; + tSender = new TelemetrySender(tsArgs); + }); + + it("should POST given ping data to telemetry.ping.endpoint pref w/fetch", + async () => { + fetchStub.resolves(fakeFetchSuccessResponse); + await tSender._sendPing(fakePingJSON); + + assert.calledOnce(fetchStub); + assert.calledWithExactly(fetchStub, fakeEndpointUrl, + {method: "POST", body: fakePingJSON}); + }); + + it("should log HTTP failures using Cu.reportError", async () => { + fetchStub.resolves(fakeFetchHttpErrorResponse); + + await tSender._sendPing(fakePingJSON); + + assert.called(Components.utils.reportError); + }); + + it("should log an error using Cu.reportError if fetch rejects", async () => { + fetchStub.rejects("Oh noes!"); + + await tSender._sendPing(fakePingJSON); + + assert.called(Components.utils.reportError); + }); + + it("should log if logging is on && if action is not activity_stream_performance", async () => { + FakePrefs.prototype.prefs = { + "telemetry": true, + "performance.log": true + }; + fetchStub.resolves(fakeFetchSuccessResponse); + tSender = new TelemetrySender(tsArgs); + + await tSender._sendPing(fakePingJSON); + + assert.called(console.log); // eslint-disable-line no-console + }); + }); + + describe("#observe()", () => { + before(() => { + globals.sandbox.stub(TelemetrySender.prototype, "_sendPing"); + }); + + observerTopics.forEach(topic => { + it(`should call this._sendPing with data for ${topic}`, () => { + const fakeSubject = "fakeSubject"; + tSender = new TelemetrySender(tsArgs); + + tSender.observe(fakeSubject, topic, fakePingJSON); + + assert.calledOnce(TelemetrySender.prototype._sendPing); + assert.calledWithExactly(TelemetrySender.prototype._sendPing, + fakePingJSON); + }); + }); + + it("should not call this._sendPing for 'nonexistent-topic'", () => { + const fakeSubject = "fakeSubject"; + tSender = new TelemetrySender(tsArgs); + + tSender.observe(fakeSubject, "nonexistent-topic", fakePingJSON); + + assert.notCalled(TelemetrySender.prototype._sendPing); + }); + }); + + describe("#uninit()", () => { + it("should remove the telemetry pref listener", () => { + tSender = new TelemetrySender(tsArgs); + assert.property(fakePrefs.observers, "telemetry"); + + tSender.uninit(); + + assert.notProperty(fakePrefs.observers, "telemetry"); + }); + + it("should remove all notification observers if telemetry pref is true", () => { + FakePrefs.prototype.prefs = {telemetry: true}; + tSender = new TelemetrySender(tsArgs); + + tSender.uninit(); + + assertNotificationObserversRemoved(); + }); + + it("should not remove notification observers if telemetry pref is false", () => { + FakePrefs.prototype.prefs = {telemetry: false}; + tSender = new TelemetrySender(tsArgs); + + tSender.uninit(); + + assert.notCalled(global.Services.obs.removeObserver); + }); + + it("should call Cu.reportError if this._prefs.ignore throws", () => { + globals.sandbox.stub(FakePrefs.prototype, "ignore").throws("Some Error"); + tSender = new TelemetrySender(tsArgs); + + tSender.uninit(); + + assert.called(global.Components.utils.reportError); + }); + }); + + describe("Misc pref changes", () => { + describe("telemetry changes from true to false", () => { + beforeEach(() => { + FakePrefs.prototype.prefs = {"telemetry": true}; + tSender = new TelemetrySender(tsArgs); + assert.propertyVal(tSender, "enabled", true); + }); + + it("should set the enabled property to false", () => { + fakePrefs.set("telemetry", false); + + assert.propertyVal(tSender, "enabled", false); + }); + + it("should remove all notification observers", () => { + fakePrefs.set("telemetry", false); + + assertNotificationObserversRemoved(); + }); + }); + + describe("telemetry changes from false to true", () => { + beforeEach(() => { + FakePrefs.prototype.prefs = {"telemetry": false}; + tSender = new TelemetrySender(tsArgs); + assert.propertyVal(tSender, "enabled", false); + }); + + it("should set the enabled property to true", () => { + fakePrefs.set("telemetry", true); + + assert.propertyVal(tSender, "enabled", true); + }); + + it("should add all topic observers", () => { + fakePrefs.set("telemetry", true); + + assertNotificationObserversAdded(); + }); + }); + + describe("performance.log changes from false to true", () => { + it("should change this.logging from false to true", () => { + FakePrefs.prototype.prefs = {"performance.log": false}; + tSender = new TelemetrySender(tsArgs); + assert.propertyVal(tSender, "logging", false); + + fakePrefs.set("performance.log", true); + + assert.propertyVal(tSender, "logging", true); + }); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js b/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js new file mode 100644 index 000000000000..2bd9adb69905 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/TopSitesFeed.test.js @@ -0,0 +1,116 @@ +"use strict"; +const {TopSitesFeed, UPDATE_TIME, TOP_SITES_SHOWMORE_LENGTH, DEFAULT_TOP_SITES} = require("lib/TopSitesFeed.jsm"); +const {GlobalOverrider} = require("test/unit/utils"); +const action = {meta: {fromTarget: {}}}; +const {actionTypes: at} = require("common/Actions.jsm"); +const FAKE_LINKS = new Array(TOP_SITES_SHOWMORE_LENGTH).fill(null).map((v, i) => ({url: `site${i}.com`})); +const FAKE_SCREENSHOT = "data123"; + +describe("Top Sites Feed", () => { + let feed; + let globals; + let sandbox; + let links; + let clock; + before(() => { + globals = new GlobalOverrider(); + sandbox = globals.sandbox; + }); + beforeEach(() => { + globals.set("PlacesProvider", {links: {getLinks: sandbox.spy(() => Promise.resolve(links))}}); + globals.set("PreviewProvider", {getThumbnail: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT))}); + feed = new TopSitesFeed(); + feed.store = {dispatch: sinon.spy(), getState() { return {TopSites: {rows: Array(12).fill("site")}}; }}; + links = FAKE_LINKS; + clock = sinon.useFakeTimers(); + }); + afterEach(() => { + globals.restore(); + clock.restore(); + }); + + it("should have default sites with .isDefault = true", () => { + DEFAULT_TOP_SITES.forEach(link => assert.propertyVal(link, "isDefault", true)); + }); + + describe("#getLinksWithDefaults", () => { + it("should get the links from Places Provider", async () => { + const result = await feed.getLinksWithDefaults(); + assert.deepEqual(result, links); + assert.calledOnce(global.PlacesProvider.links.getLinks); + }); + it("should add defaults if there are are not enough links", async () => { + links = [{url: "foo.com"}]; + const result = await feed.getLinksWithDefaults(); + assert.deepEqual(result, [{url: "foo.com"}, ...DEFAULT_TOP_SITES]); + }); + it("should only add defaults up to TOP_SITES_SHOWMORE_LENGTH", async () => { + links = new Array(TOP_SITES_SHOWMORE_LENGTH - 1).fill({url: "foo.com"}); + const result = await feed.getLinksWithDefaults(); + assert.lengthOf(result, TOP_SITES_SHOWMORE_LENGTH); + assert.deepEqual(result, [...links, DEFAULT_TOP_SITES[0]]); + }); + it("should not throw if PlacesProvider returns null", () => { + links = null; + assert.doesNotThrow(() => { + feed.getLinksWithDefaults(action); + }); + }); + }); + describe("#refresh", () => { + it("should dispatch an action with the links returned", async () => { + sandbox.stub(feed, "getScreenshot"); + await feed.refresh(action); + 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); + }); + it("should call .getScreenshot for each link", async () => { + sandbox.stub(feed, "getScreenshot"); + await feed.refresh(action); + + links.forEach(link => assert.calledWith(feed.getScreenshot, link.url)); + }); + }); + describe("getScreenshot", () => { + it("should call PreviewProvider.getThumbnail with the right url", async () => { + const url = "foo.com"; + await feed.getScreenshot(url); + assert.calledWith(global.PreviewProvider.getThumbnail, url); + }); + }); + describe("#onAction", () => { + it("should call refresh if there are not enough sites on NEW_TAB_LOAD", () => { + feed.store.getState = function() { return {TopSites: {rows: []}}; }; + sinon.stub(feed, "refresh"); + feed.onAction({type: at.NEW_TAB_LOAD}); + assert.calledOnce(feed.refresh); + }); + it("should call refresh if there are not sites on NEW_TAB_LOAD, not counting defaults", () => { + feed.store.getState = function() { return {TopSites: {rows: [{url: "foo.com"}, ...DEFAULT_TOP_SITES]}}; }; + sinon.stub(feed, "refresh"); + feed.onAction({type: at.NEW_TAB_LOAD}); + assert.calledOnce(feed.refresh); + }); + it("should not call refresh if there are enough sites on NEW_TAB_LOAD", () => { + feed.lastUpdated = Date.now(); + sinon.stub(feed, "refresh"); + feed.onAction({type: at.NEW_TAB_LOAD}); + assert.notCalled(feed.refresh); + }); + it("should call refresh if .lastUpdated is too old on NEW_TAB_LOAD", () => { + feed.lastUpdated = 0; + clock.tick(UPDATE_TIME); + sinon.stub(feed, "refresh"); + feed.onAction({type: at.NEW_TAB_LOAD}); + assert.calledOnce(feed.refresh); + }); + it("should not call refresh if .lastUpdated is less than update time on NEW_TAB_LOAD", () => { + feed.lastUpdated = 0; + clock.tick(UPDATE_TIME - 1); + sinon.stub(feed, "refresh"); + feed.onAction({type: at.NEW_TAB_LOAD}); + assert.notCalled(feed.refresh); + }); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/lib/init-store.test.js b/browser/extensions/activity-stream/test/unit/lib/init-store.test.js new file mode 100644 index 000000000000..190d632d6289 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/lib/init-store.test.js @@ -0,0 +1,43 @@ +const initStore = require("content-src/lib/init-store"); +const {GlobalOverrider, addNumberReducer} = require("test/unit/utils"); +const {actionCreators: ac} = require("common/Actions.jsm"); + +describe("initStore", () => { + let globals; + let store; + before(() => { + globals = new GlobalOverrider(); + globals.set("sendAsyncMessage", globals.sandbox.spy()); + globals.set("addMessageListener", globals.sandbox.spy()); + }); + beforeEach(() => { + store = initStore({number: addNumberReducer}); + }); + afterEach(() => globals.reset()); + after(() => globals.restore()); + it("should create a store with the provided reducers", () => { + assert.ok(store); + assert.property(store.getState(), "number"); + }); + it("should add a listener for incoming actions", () => { + assert.calledWith(global.addMessageListener, initStore.INCOMING_MESSAGE_NAME); + const callback = global.addMessageListener.firstCall.args[1]; + globals.sandbox.spy(store, "dispatch"); + const message = {name: initStore.INCOMING_MESSAGE_NAME, data: {type: "FOO"}}; + callback(message); + assert.calledWith(store.dispatch, message.data); + }); + it("should replace the state if a MERGE_STORE_ACTION is dispatched", () => { + store.dispatch({type: initStore.MERGE_STORE_ACTION, data: {number: 42}}); + assert.deepEqual(store.getState(), {number: 42}); + }); + it("should send out SendToMain ations", () => { + const action = ac.SendToMain({type: "FOO"}); + store.dispatch(action); + assert.calledWith(global.sendAsyncMessage, initStore.OUTGOING_MESSAGE_NAME, action); + }); + it("should not send out other types of ations", () => { + store.dispatch({type: "FOO"}); + assert.notCalled(global.sendAsyncMessage); + }); +}); diff --git a/browser/extensions/activity-stream/test/unit/unit-entry.js b/browser/extensions/activity-stream/test/unit/unit-entry.js new file mode 100644 index 000000000000..832c3e6d116f --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/unit-entry.js @@ -0,0 +1,38 @@ +const {GlobalOverrider} = require("test/unit/utils"); + +const req = require.context(".", true, /\.test\.js$/); +const files = req.keys(); + +// This exposes sinon assertions to chai.assert +sinon.assert.expose(assert, {prefix: ""}); + +let overrider = new GlobalOverrider(); +overrider.set({ + Components: { + interfaces: {}, + utils: { + import: overrider.sandbox.spy(), + importGlobalProperties: overrider.sandbox.spy(), + reportError: overrider.sandbox.spy() + } + }, + XPCOMUtils: { + defineLazyModuleGetter: overrider.sandbox.spy(), + defineLazyServiceGetter: overrider.sandbox.spy(), + generateQI: overrider.sandbox.stub().returns(() => {}) + }, + console: {log: overrider.sandbox.spy()}, + dump: overrider.sandbox.spy(), + Services: { + obs: { + addObserver: overrider.sandbox.spy(), + removeObserver: overrider.sandbox.spy() + } + } +}); + +describe("activity-stream", () => { + afterEach(() => overrider.reset()); + after(() => overrider.restore()); + files.forEach(file => req(file)); +}); diff --git a/browser/extensions/activity-stream/test/unit/utils.js b/browser/extensions/activity-stream/test/unit/utils.js new file mode 100644 index 000000000000..dd5e4a3a4cc1 --- /dev/null +++ b/browser/extensions/activity-stream/test/unit/utils.js @@ -0,0 +1,122 @@ +/** + * GlobalOverrider - Utility that allows you to override properties on the global object. + * See unit-entry.js for example usage. + */ +class GlobalOverrider { + constructor() { + this.originalGlobals = new Map(); + this.sandbox = sinon.sandbox.create(); + } + + /** + * _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 (key === "Components") { + // Components can be reassigned, but it will subsequently throw a deprecation + // error in Firefox which will stop execution. Adding the assignment statement + // to a try/catch block will prevent this from happening. + try { + global[key] = value; + } catch (e) {} // eslint-disable-line no-empty + return; + } + 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); + } + } + + /** + * 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; + }); + } +} + +/** + * Very simple fake for the most basic semantics of Preferences.jsm. 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. + */ +function FakePrefs(args) { + if (args) { + if ("initHook" in args) { + args.initHook.call(this); + } + } +} +FakePrefs.prototype = { + observers: {}, + observe(prefName, callback) { + this.observers[prefName] = callback; + }, + ignore(prefName, callback) { + if (prefName in this.observers) { + delete this.observers[prefName]; + } + }, + + prefs: {}, + get(prefName) { return this.prefs[prefName]; }, + set(prefName, value) { + this.prefs[prefName] = value; + + if (prefName in this.observers) { + this.observers[prefName](value); + } + } +}; + +/** + * addNumberReducer - a simple dummy reducer for testing that adds a number + */ +function addNumberReducer(prevState = 0, action) { + return action.type === "ADD" ? prevState + action.data : prevState; +} + +module.exports = { + FakePrefs, + GlobalOverrider, + addNumberReducer +}; diff --git a/browser/extensions/activity-stream/vendor/redux.js b/browser/extensions/activity-stream/vendor/redux.js new file mode 100644 index 000000000000..b83c31d3a25b --- /dev/null +++ b/browser/extensions/activity-stream/vendor/redux.js @@ -0,0 +1,948 @@ +/** + * Redux v.3.6.0 + */ +(function webpackUniversalModuleDefinition(root, factory) { + if(typeof exports === 'object' && typeof module === 'object') + module.exports = factory(); + else if(typeof define === 'function' && define.amd) + define([], factory); + else if(typeof exports === 'object') + exports["Redux"] = factory(); + else + root["Redux"] = factory(); +})(this, function() { +return /******/ (function(modules) { // webpackBootstrap +/******/ // The module cache +/******/ var installedModules = {}; + +/******/ // The require function +/******/ function __webpack_require__(moduleId) { + +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) +/******/ return installedModules[moduleId].exports; + +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ exports: {}, +/******/ id: moduleId, +/******/ loaded: false +/******/ }; + +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); + +/******/ // Flag the module as loaded +/******/ module.loaded = true; + +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } + + +/******/ // expose the modules object (__webpack_modules__) +/******/ __webpack_require__.m = modules; + +/******/ // expose the module cache +/******/ __webpack_require__.c = installedModules; + +/******/ // __webpack_public_path__ +/******/ __webpack_require__.p = ""; + +/******/ // Load entry module and return exports +/******/ return __webpack_require__(0); +/******/ }) +/************************************************************************/ +/******/ ([ +/* 0 */ +/***/ function(module, exports, __webpack_require__) { + + 'use strict'; + + exports.__esModule = true; + exports.compose = exports.applyMiddleware = exports.bindActionCreators = exports.combineReducers = exports.createStore = undefined; + + var _createStore = __webpack_require__(2); + + var _createStore2 = _interopRequireDefault(_createStore); + + var _combineReducers = __webpack_require__(7); + + var _combineReducers2 = _interopRequireDefault(_combineReducers); + + var _bindActionCreators = __webpack_require__(6); + + var _bindActionCreators2 = _interopRequireDefault(_bindActionCreators); + + var _applyMiddleware = __webpack_require__(5); + + var _applyMiddleware2 = _interopRequireDefault(_applyMiddleware); + + var _compose = __webpack_require__(1); + + var _compose2 = _interopRequireDefault(_compose); + + var _warning = __webpack_require__(3); + + var _warning2 = _interopRequireDefault(_warning); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + + /* + * This is a dummy function to check if the function name has been altered by minification. + * If the function has been minified and NODE_ENV !== 'production', warn the user. + */ + function isCrushed() {} + + if (("development") !== 'production' && typeof isCrushed.name === 'string' && isCrushed.name !== 'isCrushed') { + (0, _warning2['default'])('You are currently using minified code outside of NODE_ENV === \'production\'. ' + 'This means that you are running a slower development build of Redux. ' + 'You can use loose-envify (https://github.com/zertosh/loose-envify) for browserify ' + 'or DefinePlugin for webpack (http://stackoverflow.com/questions/30030031) ' + 'to ensure you have the correct code for your production build.'); + } + + exports.createStore = _createStore2['default']; + exports.combineReducers = _combineReducers2['default']; + exports.bindActionCreators = _bindActionCreators2['default']; + exports.applyMiddleware = _applyMiddleware2['default']; + exports.compose = _compose2['default']; + +/***/ }, +/* 1 */ +/***/ function(module, exports) { + + "use strict"; + + exports.__esModule = true; + exports["default"] = compose; + /** + * Composes single-argument functions from right to left. The rightmost + * function can take multiple arguments as it provides the signature for + * the resulting composite function. + * + * @param {...Function} funcs The functions to compose. + * @returns {Function} A function obtained by composing the argument functions + * from right to left. For example, compose(f, g, h) is identical to doing + * (...args) => f(g(h(...args))). + */ + + function compose() { + for (var _len = arguments.length, funcs = Array(_len), _key = 0; _key < _len; _key++) { + funcs[_key] = arguments[_key]; + } + + if (funcs.length === 0) { + return function (arg) { + return arg; + }; + } + + if (funcs.length === 1) { + return funcs[0]; + } + + var last = funcs[funcs.length - 1]; + var rest = funcs.slice(0, -1); + return function () { + return rest.reduceRight(function (composed, f) { + return f(composed); + }, last.apply(undefined, arguments)); + }; + } + +/***/ }, +/* 2 */ +/***/ function(module, exports, __webpack_require__) { + + 'use strict'; + + exports.__esModule = true; + exports.ActionTypes = undefined; + exports['default'] = createStore; + + var _isPlainObject = __webpack_require__(4); + + var _isPlainObject2 = _interopRequireDefault(_isPlainObject); + + var _symbolObservable = __webpack_require__(12); + + var _symbolObservable2 = _interopRequireDefault(_symbolObservable); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + + /** + * These are private action types reserved by Redux. + * For any unknown actions, you must return the current state. + * If the current state is undefined, you must return the initial state. + * Do not reference these action types directly in your code. + */ + var ActionTypes = exports.ActionTypes = { + INIT: '@@redux/INIT' + }; + + /** + * Creates a Redux store that holds the state tree. + * The only way to change the data in the store is to call `dispatch()` on it. + * + * There should only be a single store in your app. To specify how different + * parts of the state tree respond to actions, you may combine several reducers + * into a single reducer function by using `combineReducers`. + * + * @param {Function} reducer A function that returns the next state tree, given + * the current state tree and the action to handle. + * + * @param {any} [preloadedState] The initial state. You may optionally specify it + * to hydrate the state from the server in universal apps, or to restore a + * previously serialized user session. + * If you use `combineReducers` to produce the root reducer function, this must be + * an object with the same shape as `combineReducers` keys. + * + * @param {Function} enhancer The store enhancer. You may optionally specify it + * to enhance the store with third-party capabilities such as middleware, + * time travel, persistence, etc. The only store enhancer that ships with Redux + * is `applyMiddleware()`. + * + * @returns {Store} A Redux store that lets you read the state, dispatch actions + * and subscribe to changes. + */ + function createStore(reducer, preloadedState, enhancer) { + var _ref2; + + if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') { + enhancer = preloadedState; + preloadedState = undefined; + } + + if (typeof enhancer !== 'undefined') { + if (typeof enhancer !== 'function') { + throw new Error('Expected the enhancer to be a function.'); + } + + return enhancer(createStore)(reducer, preloadedState); + } + + if (typeof reducer !== 'function') { + throw new Error('Expected the reducer to be a function.'); + } + + var currentReducer = reducer; + var currentState = preloadedState; + var currentListeners = []; + var nextListeners = currentListeners; + var isDispatching = false; + + function ensureCanMutateNextListeners() { + if (nextListeners === currentListeners) { + nextListeners = currentListeners.slice(); + } + } + + /** + * Reads the state tree managed by the store. + * + * @returns {any} The current state tree of your application. + */ + function getState() { + return currentState; + } + + /** + * Adds a change listener. It will be called any time an action is dispatched, + * and some part of the state tree may potentially have changed. You may then + * call `getState()` to read the current state tree inside the callback. + * + * You may call `dispatch()` from a change listener, with the following + * caveats: + * + * 1. The subscriptions are snapshotted just before every `dispatch()` call. + * If you subscribe or unsubscribe while the listeners are being invoked, this + * will not have any effect on the `dispatch()` that is currently in progress. + * However, the next `dispatch()` call, whether nested or not, will use a more + * recent snapshot of the subscription list. + * + * 2. The listener should not expect to see all state changes, as the state + * might have been updated multiple times during a nested `dispatch()` before + * the listener is called. It is, however, guaranteed that all subscribers + * registered before the `dispatch()` started will be called with the latest + * state by the time it exits. + * + * @param {Function} listener A callback to be invoked on every dispatch. + * @returns {Function} A function to remove this change listener. + */ + function subscribe(listener) { + if (typeof listener !== 'function') { + throw new Error('Expected listener to be a function.'); + } + + var isSubscribed = true; + + ensureCanMutateNextListeners(); + nextListeners.push(listener); + + return function unsubscribe() { + if (!isSubscribed) { + return; + } + + isSubscribed = false; + + ensureCanMutateNextListeners(); + var index = nextListeners.indexOf(listener); + nextListeners.splice(index, 1); + }; + } + + /** + * Dispatches an action. It is the only way to trigger a state change. + * + * The `reducer` function, used to create the store, will be called with the + * current state tree and the given `action`. Its return value will + * be considered the **next** state of the tree, and the change listeners + * will be notified. + * + * The base implementation only supports plain object actions. If you want to + * dispatch a Promise, an Observable, a thunk, or something else, you need to + * wrap your store creating function into the corresponding middleware. For + * example, see the documentation for the `redux-thunk` package. Even the + * middleware will eventually dispatch plain object actions using this method. + * + * @param {Object} action A plain object representing “what changed”. It is + * a good idea to keep actions serializable so you can record and replay user + * sessions, or use the time travelling `redux-devtools`. An action must have + * a `type` property which may not be `undefined`. It is a good idea to use + * string constants for action types. + * + * @returns {Object} For convenience, the same action object you dispatched. + * + * Note that, if you use a custom middleware, it may wrap `dispatch()` to + * return something else (for example, a Promise you can await). + */ + function dispatch(action) { + if (!(0, _isPlainObject2['default'])(action)) { + throw new Error('Actions must be plain objects. ' + 'Use custom middleware for async actions.'); + } + + if (typeof action.type === 'undefined') { + throw new Error('Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?'); + } + + if (isDispatching) { + throw new Error('Reducers may not dispatch actions.'); + } + + try { + isDispatching = true; + currentState = currentReducer(currentState, action); + } finally { + isDispatching = false; + } + + var listeners = currentListeners = nextListeners; + for (var i = 0; i < listeners.length; i++) { + listeners[i](); + } + + return action; + } + + /** + * Replaces the reducer currently used by the store to calculate the state. + * + * You might need this if your app implements code splitting and you want to + * load some of the reducers dynamically. You might also need this if you + * implement a hot reloading mechanism for Redux. + * + * @param {Function} nextReducer The reducer for the store to use instead. + * @returns {void} + */ + function replaceReducer(nextReducer) { + if (typeof nextReducer !== 'function') { + throw new Error('Expected the nextReducer to be a function.'); + } + + currentReducer = nextReducer; + dispatch({ type: ActionTypes.INIT }); + } + + /** + * Interoperability point for observable/reactive libraries. + * @returns {observable} A minimal observable of state changes. + * For more information, see the observable proposal: + * https://github.com/zenparsing/es-observable + */ + function observable() { + var _ref; + + var outerSubscribe = subscribe; + return _ref = { + /** + * The minimal observable subscription method. + * @param {Object} observer Any object that can be used as an observer. + * The observer object should have a `next` method. + * @returns {subscription} An object with an `unsubscribe` method that can + * be used to unsubscribe the observable from the store, and prevent further + * emission of values from the observable. + */ + subscribe: function subscribe(observer) { + if (typeof observer !== 'object') { + throw new TypeError('Expected the observer to be an object.'); + } + + function observeState() { + if (observer.next) { + observer.next(getState()); + } + } + + observeState(); + var unsubscribe = outerSubscribe(observeState); + return { unsubscribe: unsubscribe }; + } + }, _ref[_symbolObservable2['default']] = function () { + return this; + }, _ref; + } + + // When a store is created, an "INIT" action is dispatched so that every + // reducer returns their initial state. This effectively populates + // the initial state tree. + dispatch({ type: ActionTypes.INIT }); + + return _ref2 = { + dispatch: dispatch, + subscribe: subscribe, + getState: getState, + replaceReducer: replaceReducer + }, _ref2[_symbolObservable2['default']] = observable, _ref2; + } + +/***/ }, +/* 3 */ +/***/ function(module, exports) { + + 'use strict'; + + exports.__esModule = true; + exports['default'] = warning; + /** + * Prints a warning in the console if it exists. + * + * @param {String} message The warning message. + * @returns {void} + */ + function warning(message) { + /* eslint-disable no-console */ + if (typeof console !== 'undefined' && typeof console.error === 'function') { + console.error(message); + } + /* eslint-enable no-console */ + try { + // This error was thrown as a convenience so that if you enable + // "break on all exceptions" in your console, + // it would pause the execution at this line. + throw new Error(message); + /* eslint-disable no-empty */ + } catch (e) {} + /* eslint-enable no-empty */ + } + +/***/ }, +/* 4 */ +/***/ function(module, exports, __webpack_require__) { + + var getPrototype = __webpack_require__(8), + isHostObject = __webpack_require__(9), + isObjectLike = __webpack_require__(11); + + /** `Object#toString` result references. */ + var objectTag = '[object Object]'; + + /** Used for built-in method references. */ + var funcProto = Function.prototype, + objectProto = Object.prototype; + + /** Used to resolve the decompiled source of functions. */ + var funcToString = funcProto.toString; + + /** Used to check objects for own properties. */ + var hasOwnProperty = objectProto.hasOwnProperty; + + /** Used to infer the `Object` constructor. */ + var objectCtorString = funcToString.call(Object); + + /** + * Used to resolve the + * [`toStringTag`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) + * of values. + */ + var objectToString = objectProto.toString; + + /** + * Checks if `value` is a plain object, that is, an object created by the + * `Object` constructor or one with a `[[Prototype]]` of `null`. + * + * @static + * @memberOf _ + * @since 0.8.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + * @example + * + * function Foo() { + * this.a = 1; + * } + * + * _.isPlainObject(new Foo); + * // => false + * + * _.isPlainObject([1, 2, 3]); + * // => false + * + * _.isPlainObject({ 'x': 0, 'y': 0 }); + * // => true + * + * _.isPlainObject(Object.create(null)); + * // => true + */ + function isPlainObject(value) { + if (!isObjectLike(value) || + objectToString.call(value) != objectTag || isHostObject(value)) { + return false; + } + var proto = getPrototype(value); + if (proto === null) { + return true; + } + var Ctor = hasOwnProperty.call(proto, 'constructor') && proto.constructor; + return (typeof Ctor == 'function' && + Ctor instanceof Ctor && funcToString.call(Ctor) == objectCtorString); + } + + module.exports = isPlainObject; + + +/***/ }, +/* 5 */ +/***/ function(module, exports, __webpack_require__) { + + 'use strict'; + + exports.__esModule = true; + + var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + + exports['default'] = applyMiddleware; + + var _compose = __webpack_require__(1); + + var _compose2 = _interopRequireDefault(_compose); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + + /** + * Creates a store enhancer that applies middleware to the dispatch method + * of the Redux store. This is handy for a variety of tasks, such as expressing + * asynchronous actions in a concise manner, or logging every action payload. + * + * See `redux-thunk` package as an example of the Redux middleware. + * + * Because middleware is potentially asynchronous, this should be the first + * store enhancer in the composition chain. + * + * Note that each middleware will be given the `dispatch` and `getState` functions + * as named arguments. + * + * @param {...Function} middlewares The middleware chain to be applied. + * @returns {Function} A store enhancer applying the middleware. + */ + function applyMiddleware() { + for (var _len = arguments.length, middlewares = Array(_len), _key = 0; _key < _len; _key++) { + middlewares[_key] = arguments[_key]; + } + + return function (createStore) { + return function (reducer, preloadedState, enhancer) { + var store = createStore(reducer, preloadedState, enhancer); + var _dispatch = store.dispatch; + var chain = []; + + var middlewareAPI = { + getState: store.getState, + dispatch: function dispatch(action) { + return _dispatch(action); + } + }; + chain = middlewares.map(function (middleware) { + return middleware(middlewareAPI); + }); + _dispatch = _compose2['default'].apply(undefined, chain)(store.dispatch); + + return _extends({}, store, { + dispatch: _dispatch + }); + }; + }; + } + +/***/ }, +/* 6 */ +/***/ function(module, exports) { + + 'use strict'; + + exports.__esModule = true; + exports['default'] = bindActionCreators; + function bindActionCreator(actionCreator, dispatch) { + return function () { + return dispatch(actionCreator.apply(undefined, arguments)); + }; + } + + /** + * Turns an object whose values are action creators, into an object with the + * same keys, but with every function wrapped into a `dispatch` call so they + * may be invoked directly. This is just a convenience method, as you can call + * `store.dispatch(MyActionCreators.doSomething())` yourself just fine. + * + * For convenience, you can also pass a single function as the first argument, + * and get a function in return. + * + * @param {Function|Object} actionCreators An object whose values are action + * creator functions. One handy way to obtain it is to use ES6 `import * as` + * syntax. You may also pass a single function. + * + * @param {Function} dispatch The `dispatch` function available on your Redux + * store. + * + * @returns {Function|Object} The object mimicking the original object, but with + * every action creator wrapped into the `dispatch` call. If you passed a + * function as `actionCreators`, the return value will also be a single + * function. + */ + function bindActionCreators(actionCreators, dispatch) { + if (typeof actionCreators === 'function') { + return bindActionCreator(actionCreators, dispatch); + } + + if (typeof actionCreators !== 'object' || actionCreators === null) { + throw new Error('bindActionCreators expected an object or a function, instead received ' + (actionCreators === null ? 'null' : typeof actionCreators) + '. ' + 'Did you write "import ActionCreators from" instead of "import * as ActionCreators from"?'); + } + + var keys = Object.keys(actionCreators); + var boundActionCreators = {}; + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var actionCreator = actionCreators[key]; + if (typeof actionCreator === 'function') { + boundActionCreators[key] = bindActionCreator(actionCreator, dispatch); + } + } + return boundActionCreators; + } + +/***/ }, +/* 7 */ +/***/ function(module, exports, __webpack_require__) { + + 'use strict'; + + exports.__esModule = true; + exports['default'] = combineReducers; + + var _createStore = __webpack_require__(2); + + var _isPlainObject = __webpack_require__(4); + + var _isPlainObject2 = _interopRequireDefault(_isPlainObject); + + var _warning = __webpack_require__(3); + + var _warning2 = _interopRequireDefault(_warning); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + + function getUndefinedStateErrorMessage(key, action) { + var actionType = action && action.type; + var actionName = actionType && '"' + actionType.toString() + '"' || 'an action'; + + return 'Given action ' + actionName + ', reducer "' + key + '" returned undefined. ' + 'To ignore an action, you must explicitly return the previous state.'; + } + + function getUnexpectedStateShapeWarningMessage(inputState, reducers, action, unexpectedKeyCache) { + var reducerKeys = Object.keys(reducers); + var argumentName = action && action.type === _createStore.ActionTypes.INIT ? 'preloadedState argument passed to createStore' : 'previous state received by the reducer'; + + if (reducerKeys.length === 0) { + return 'Store does not have a valid reducer. Make sure the argument passed ' + 'to combineReducers is an object whose values are reducers.'; + } + + if (!(0, _isPlainObject2['default'])(inputState)) { + return 'The ' + argumentName + ' has unexpected type of "' + {}.toString.call(inputState).match(/\s([a-z|A-Z]+)/)[1] + '". Expected argument to be an object with the following ' + ('keys: "' + reducerKeys.join('", "') + '"'); + } + + var unexpectedKeys = Object.keys(inputState).filter(function (key) { + return !reducers.hasOwnProperty(key) && !unexpectedKeyCache[key]; + }); + + unexpectedKeys.forEach(function (key) { + unexpectedKeyCache[key] = true; + }); + + if (unexpectedKeys.length > 0) { + return 'Unexpected ' + (unexpectedKeys.length > 1 ? 'keys' : 'key') + ' ' + ('"' + unexpectedKeys.join('", "') + '" found in ' + argumentName + '. ') + 'Expected to find one of the known reducer keys instead: ' + ('"' + reducerKeys.join('", "') + '". Unexpected keys will be ignored.'); + } + } + + function assertReducerSanity(reducers) { + Object.keys(reducers).forEach(function (key) { + var reducer = reducers[key]; + var initialState = reducer(undefined, { type: _createStore.ActionTypes.INIT }); + + if (typeof initialState === 'undefined') { + throw new Error('Reducer "' + key + '" returned undefined during initialization. ' + 'If the state passed to the reducer is undefined, you must ' + 'explicitly return the initial state. The initial state may ' + 'not be undefined.'); + } + + var type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.'); + if (typeof reducer(undefined, { type: type }) === 'undefined') { + throw new Error('Reducer "' + key + '" returned undefined when probed with a random type. ' + ('Don\'t try to handle ' + _createStore.ActionTypes.INIT + ' or other actions in "redux/*" ') + 'namespace. They are considered private. Instead, you must return the ' + 'current state for any unknown actions, unless it is undefined, ' + 'in which case you must return the initial state, regardless of the ' + 'action type. The initial state may not be undefined.'); + } + }); + } + + /** + * Turns an object whose values are different reducer functions, into a single + * reducer function. It will call every child reducer, and gather their results + * into a single state object, whose keys correspond to the keys of the passed + * reducer functions. + * + * @param {Object} reducers An object whose values correspond to different + * reducer functions that need to be combined into one. One handy way to obtain + * it is to use ES6 `import * as reducers` syntax. The reducers may never return + * undefined for any action. Instead, they should return their initial state + * if the state passed to them was undefined, and the current state for any + * unrecognized action. + * + * @returns {Function} A reducer function that invokes every reducer inside the + * passed object, and builds a state object with the same shape. + */ + function combineReducers(reducers) { + var reducerKeys = Object.keys(reducers); + var finalReducers = {}; + for (var i = 0; i < reducerKeys.length; i++) { + var key = reducerKeys[i]; + + if (true) { + if (typeof reducers[key] === 'undefined') { + (0, _warning2['default'])('No reducer provided for key "' + key + '"'); + } + } + + if (typeof reducers[key] === 'function') { + finalReducers[key] = reducers[key]; + } + } + var finalReducerKeys = Object.keys(finalReducers); + + if (true) { + var unexpectedKeyCache = {}; + } + + var sanityError; + try { + assertReducerSanity(finalReducers); + } catch (e) { + sanityError = e; + } + + return function combination() { + var state = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; + var action = arguments[1]; + + if (sanityError) { + throw sanityError; + } + + if (true) { + var warningMessage = getUnexpectedStateShapeWarningMessage(state, finalReducers, action, unexpectedKeyCache); + if (warningMessage) { + (0, _warning2['default'])(warningMessage); + } + } + + var hasChanged = false; + var nextState = {}; + for (var i = 0; i < finalReducerKeys.length; i++) { + var key = finalReducerKeys[i]; + var reducer = finalReducers[key]; + var previousStateForKey = state[key]; + var nextStateForKey = reducer(previousStateForKey, action); + if (typeof nextStateForKey === 'undefined') { + var errorMessage = getUndefinedStateErrorMessage(key, action); + throw new Error(errorMessage); + } + nextState[key] = nextStateForKey; + hasChanged = hasChanged || nextStateForKey !== previousStateForKey; + } + return hasChanged ? nextState : state; + }; + } + +/***/ }, +/* 8 */ +/***/ function(module, exports, __webpack_require__) { + + var overArg = __webpack_require__(10); + + /** Built-in value references. */ + var getPrototype = overArg(Object.getPrototypeOf, Object); + + module.exports = getPrototype; + + +/***/ }, +/* 9 */ +/***/ function(module, exports) { + + /** + * Checks if `value` is a host object in IE < 9. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a host object, else `false`. + */ + function isHostObject(value) { + // Many host objects are `Object` objects that can coerce to strings + // despite having improperly defined `toString` methods. + var result = false; + if (value != null && typeof value.toString != 'function') { + try { + result = !!(value + ''); + } catch (e) {} + } + return result; + } + + module.exports = isHostObject; + + +/***/ }, +/* 10 */ +/***/ function(module, exports) { + + /** + * Creates a unary function that invokes `func` with its argument transformed. + * + * @private + * @param {Function} func The function to wrap. + * @param {Function} transform The argument transform. + * @returns {Function} Returns the new function. + */ + function overArg(func, transform) { + return function(arg) { + return func(transform(arg)); + }; + } + + module.exports = overArg; + + +/***/ }, +/* 11 */ +/***/ function(module, exports) { + + /** + * Checks if `value` is object-like. A value is object-like if it's not `null` + * and has a `typeof` result of "object". + * + * @static + * @memberOf _ + * @since 4.0.0 + * @category Lang + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is object-like, else `false`. + * @example + * + * _.isObjectLike({}); + * // => true + * + * _.isObjectLike([1, 2, 3]); + * // => true + * + * _.isObjectLike(_.noop); + * // => false + * + * _.isObjectLike(null); + * // => false + */ + function isObjectLike(value) { + return !!value && typeof value == 'object'; + } + + module.exports = isObjectLike; + + +/***/ }, +/* 12 */ +/***/ function(module, exports, __webpack_require__) { + + module.exports = __webpack_require__(13); + + +/***/ }, +/* 13 */ +/***/ function(module, exports, __webpack_require__) { + + /* WEBPACK VAR INJECTION */(function(global) {'use strict'; + + Object.defineProperty(exports, "__esModule", { + value: true + }); + + var _ponyfill = __webpack_require__(14); + + var _ponyfill2 = _interopRequireDefault(_ponyfill); + + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } + + var root = undefined; /* global window */ + + if (typeof global !== 'undefined') { + root = global; + } else if (typeof window !== 'undefined') { + root = window; + } + + var result = (0, _ponyfill2['default'])(root); + exports['default'] = result; + /* WEBPACK VAR INJECTION */}.call(exports, (function() { return this; }()))) + +/***/ }, +/* 14 */ +/***/ function(module, exports) { + + 'use strict'; + + Object.defineProperty(exports, "__esModule", { + value: true + }); + exports['default'] = symbolObservablePonyfill; + function symbolObservablePonyfill(root) { + var result; + var _Symbol = root.Symbol; + + if (typeof _Symbol === 'function') { + if (_Symbol.observable) { + result = _Symbol.observable; + } else { + result = _Symbol('observable'); + _Symbol.observable = result; + } + } else { + result = '@@observable'; + } + + return result; + }; + +/***/ } +/******/ ]) +}); +;