diff --git a/__mocks__/@electron/remote.js b/__mocks__/@electron/remote.js index 0a7a1d42..ac02eb2b 100644 --- a/__mocks__/@electron/remote.js +++ b/__mocks__/@electron/remote.js @@ -1,5 +1,6 @@ module.exports = { app: { getPath: () => '', + getLocale: () => '', }, } diff --git a/__mocks__/electron.js b/__mocks__/electron.js index 34a8edb5..70fc8981 100644 --- a/__mocks__/electron.js +++ b/__mocks__/electron.js @@ -1,6 +1,7 @@ module.exports = { app: { getPath: jest.fn(), + getLocale: jest.fn(), }, ipcRenderer: { on: jest.fn(), diff --git a/app/background/background.js b/app/background/background.js index dd950457..22ac01de 100644 --- a/app/background/background.js +++ b/app/background/background.js @@ -15,9 +15,8 @@ on('initializePluginAsync', ({ name }) => { console.group(`Initialize async plugin ${name}`) try { - const { initializeAsync } = plugins[name] - ? plugins[name] - : window.require(`${modulesDirectory}/${name}`) + const plugin = plugins[name] || window.require(`${modulesDirectory}/${name}`) + const { initializeAsync } = plugin if (!initializeAsync) { console.log('no `initializeAsync` function, skipped') @@ -29,11 +28,11 @@ on('initializePluginAsync', ({ name }) => { console.log('Done! Sending data back to main window') // Send message back to main window with initialization result send('plugin.message', { name, data }) - }, pluginSettings.getUserSettings(name)) + }, pluginSettings.getUserSettings(plugin, name)) } catch (err) { console.log('Failed', err) } console.groupEnd() }) // Handle `reload` rpc event and reload window -on('reload', () => location.reload()) +on('reload', () => window.location.reload()) diff --git a/app/lib/__tests__/loadThemes.spec.js b/app/lib/__tests__/loadThemes.spec.js new file mode 100644 index 00000000..b07d96cb --- /dev/null +++ b/app/lib/__tests__/loadThemes.spec.js @@ -0,0 +1,32 @@ +import themesLoader from '../loadThemes' + +const productionThemes = [ + { + value: '../dist/main/css/themes/light.css', + label: 'Light' + }, + { + value: '../dist/main/css/themes/dark.css', + label: 'Dark' + } +] + +const developmentThemes = [ + { + value: 'http://localhost:3000/dist/main/css/themes/light.css', + label: 'Light' + }, + { + value: 'http://localhost:3000/dist/main/css/themes/dark.css', + label: 'Dark' + } +] + +test('returns themes for production', () => { + expect(themesLoader()).toEqual(productionThemes) +}) + +test('returns themes for development', () => { + process.env.NODE_ENV = 'development' + expect(themesLoader()).toEqual(developmentThemes) +}) diff --git a/app/lib/config.js b/app/lib/config.js index 254e9975..f23d4102 100644 --- a/app/lib/config.js +++ b/app/lib/config.js @@ -47,6 +47,16 @@ const readConfig = () => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE).toString()) } catch (err) { + const config = defaultSettings() + + if (err.code !== 'ENOENT') { + console.error('Error reading config file', err) + return config + } + + if (process.env.NODE_ENV === 'test') return config + + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)) return defaultSettings() } } @@ -57,14 +67,7 @@ const readConfig = () => { * @return {Any} */ const get = (key) => { - let config - - if (!fs.existsSync(CONFIG_FILE)) { - // Save default config to local storage - config = defaultSettings() - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)) - } else { config = readConfig() } - + const config = readConfig() return config[key] } diff --git a/app/lib/initPlugin.js b/app/lib/initPlugin.js new file mode 100644 index 00000000..95e27291 --- /dev/null +++ b/app/lib/initPlugin.js @@ -0,0 +1,29 @@ +import { send } from 'lib/rpc' +import { settings as pluginSettings } from 'lib/plugins' + +/** + * Initialices plugin sync and/or async by calling the `initialize` and `initializeAsync` functions + * @param {Object} plugin A plugin object + * @param {string} name The name entry in the plugin package.json + */ +const initPlugin = (plugin, name) => { + const { initialize, initializeAsync } = plugin + + // Foreground plugin initialization + if (initialize) { + console.log('Initialize sync plugin', name) + try { + initialize(pluginSettings.getUserSettings(plugin, name)) + } catch (e) { + console.error(`Failed to initialize plugin: ${name}`, e) + } + } + + // Background plugin initialization + if (initializeAsync) { + console.log('Initialize async plugin', name) + send('initializePluginAsync', { name }) + } +} + +export default initPlugin diff --git a/app/lib/initializePlugins.js b/app/lib/initializePlugins.js index 8a733ccc..1112434b 100644 --- a/app/lib/initializePlugins.js +++ b/app/lib/initializePlugins.js @@ -1,27 +1,9 @@ -import { on, send } from 'lib/rpc' +import { on } from 'lib/rpc' import plugins from 'plugins' -import { settings as pluginSettings } from 'lib/plugins' - -export const initializePlugin = (name) => { - const { initialize, initializeAsync } = plugins[name] - - if (initialize) { - // Foreground plugin initialization - try { - initialize(pluginSettings.getUserSettings(name)) - } catch (e) { - console.error(`Failed to initialize plugin: ${name}`, e) - } - } - - if (initializeAsync) { - // Background plugin initialization - send('initializePluginAsync', { name }) - } -} +import initPlugin from './initPlugin' /** - * RPC-call for plugins initializations + * Starts listening for `initializePlugin` events and initializes all plugins */ export default () => { // Start listening for replies from plugin async initializers @@ -30,5 +12,5 @@ export default () => { if (plugin.onMessage) plugin.onMessage(data) }) - Object.keys(plugins).forEach(initializePlugin) + Object.keys(plugins).forEach((name) => initPlugin(plugins[name], name)) } diff --git a/app/lib/plugins/settings/__tests__/get.spec.js b/app/lib/plugins/settings/__tests__/get.spec.js new file mode 100644 index 00000000..090f3ba7 --- /dev/null +++ b/app/lib/plugins/settings/__tests__/get.spec.js @@ -0,0 +1,21 @@ +import getUserSettings from '../get' + +const plugin = { + settings: { + test_setting1: { + type: 'string', + defaultValue: 'test', + }, + test_setting2: { + type: 'number', + defaultValue: 1, + }, + } +} + +describe('Test getUserSettings', () => { + it('returns valid settings object', () => { + expect(getUserSettings(plugin, 'test-plugin')) + .toEqual({ test_setting1: 'test', test_setting2: 1 }) + }) +}) diff --git a/app/lib/plugins/settings/__tests__/validate.spec.js b/app/lib/plugins/settings/__tests__/validate.spec.js new file mode 100644 index 00000000..b2aa6fdd --- /dev/null +++ b/app/lib/plugins/settings/__tests__/validate.spec.js @@ -0,0 +1,84 @@ +import validate from '../validate' + +const validSettings = { + option1: { + description: 'Just a test description', + type: 'option', + options: ['option_1', 'option_2'], + }, + option2: { + description: 'Just a test description', + type: 'number', + defaultValue: 0 + }, + option3: { + description: 'Just a test description', + type: 'number', + defaultValue: 0 + }, + option4: { + description: 'Just a test description', + type: 'bool' + }, + option5: { + description: 'Just a test description', + type: 'string', + defaultValue: 'test' + } +} + +const invalidSettingsNoOptionsProvided = { + option1: { + description: 'Just a test description', + type: 'option', + options: [], + } +} + +const invalidSettingsInvalidType = { + option1: { + description: 'Just a test description', + type: 'test' + } +} + +describe('Validate settings function', () => { + it('returns true when plugin has no settings field', () => { + const plugin = { + fn: () => {} + } + expect(validate(plugin)).toEqual(true) + }) + + it('returns true when plugin has empty settings field', () => { + const plugin = { + fn: () => {}, + settings: {} + } + expect(validate(plugin)).toEqual(true) + }) + + it('returns true when plugin has valid settings', () => { + const plugin = { + fn: () => {}, + settings: validSettings + } + expect(validate(plugin)).toEqual(true) + }) + + it('returns false when option type is options and no options provided', () => { + const plugin = { + fn: () => {}, + settings: invalidSettingsNoOptionsProvided + } + expect(validate(plugin)).toEqual(false) + }) + + it('returns false when option type is incorrect', () => { + const plugin = { + fn: () => {}, + settings: invalidSettingsInvalidType + } + expect(validate(plugin)).toEqual(false) + }) +}) diff --git a/app/lib/plugins/settings/get.js b/app/lib/plugins/settings/get.js index dc7685d4..6748c95f 100644 --- a/app/lib/plugins/settings/get.js +++ b/app/lib/plugins/settings/get.js @@ -1,19 +1,34 @@ import config from 'lib/config' -import plugins from 'plugins' -const getSettings = pluginName => config.get('plugins')[pluginName] || {} +/** + * Returns the settings established by the user and previously saved in the config file + * @param {string} pluginName The name entry of the plugin package.json + * @returns An object with keys and values of the **stored** plugin settings + */ +const getExistingSettings = (pluginName) => config.get('plugins')[pluginName] || {} -const getUserSettings = (pluginName) => { - const settings = getSettings(pluginName) +/** + * Returns the sum of the default settings and the user settings + * We use packageJsonName to avoid conflicts with plugins that export + * a different name from the bundle. Two plugins can export the same name + * but can't have the same package.json name + * @param {Object} plugin + * @param {string} packageJsonName + * @returns An object with keys and values of the plugin settings + */ +const getUserSettings = (plugin, packageJsonName) => { + const userSettings = {} + const existingSettings = getExistingSettings(packageJsonName) + const { settings: pluginSettings } = plugin - if (plugins[pluginName].settings) { + if (pluginSettings) { // Provide default values if nothing is set by user - Object.keys(plugins[pluginName].settings).forEach((key) => { - settings[key] = settings[key] || plugins[pluginName].settings[key].defaultValue + Object.keys(pluginSettings).forEach((key) => { + userSettings[key] = existingSettings[key] || pluginSettings[key].defaultValue }) } - return settings + return userSettings } export default getUserSettings diff --git a/app/main/actions/search.spec.js b/app/main/actions/__tests__/search.spec.js similarity index 97% rename from app/main/actions/search.spec.js rename to app/main/actions/__tests__/search.spec.js index c3743cc6..8e5d1fc3 100644 --- a/app/main/actions/search.spec.js +++ b/app/main/actions/__tests__/search.spec.js @@ -10,7 +10,7 @@ import { RESET, } from 'main/constants/actionTypes' -import * as actions from './search' +import * as actions from '../search' describe('reset', () => { it('returns valid action', () => { diff --git a/app/main/actions/__tests__/statusBar.spec.js b/app/main/actions/__tests__/statusBar.spec.js new file mode 100644 index 00000000..bd6f70ee --- /dev/null +++ b/app/main/actions/__tests__/statusBar.spec.js @@ -0,0 +1,27 @@ +/** + * @jest-environment jsdom + */ + +import { + SET_STATUS_BAR_TEXT +} from 'main/constants/actionTypes' + +import * as actions from '../statusBar' + +describe('reset', () => { + it('returns valid action', () => { + expect(actions.reset()).toEqual({ + type: SET_STATUS_BAR_TEXT, + payload: null + }) + }) +}) + +describe('setValue', () => { + it('returns valid action when value passed', () => { + expect(actions.setValue('test value')).toEqual({ + type: SET_STATUS_BAR_TEXT, + payload: 'test value' + }) + }) +}) diff --git a/app/main/actions/search.js b/app/main/actions/search.js index 4fb70476..f8e23657 100644 --- a/app/main/actions/search.js +++ b/app/main/actions/search.js @@ -28,10 +28,10 @@ const remote = process.type === 'browser' const DEFAULT_SCOPE = { config, actions: { - open: q => shell.openExternal(q), - reveal: q => shell.showItemInFolder(q), - copyToClipboard: q => clipboard.writeText(q), - replaceTerm: term => store.dispatch(updateTerm(term)), + open: (q) => shell.openExternal(q), + reveal: (q) => shell.showItemInFolder(q), + copyToClipboard: (q) => clipboard.writeText(q), + replaceTerm: (term) => store.dispatch(updateTerm(term)), hideWindow: () => remote.getCurrentWindow().hide() } } @@ -39,19 +39,20 @@ const DEFAULT_SCOPE = { /** * Pass search term to all plugins and handle their results * @param {String} term Search term - * @param {Function} callback Callback function that receives used search term and found results + * @param {Function} display Callback function that receives used search term and found results */ const eachPlugin = (term, display) => { // TODO: order results by frequency? Object.keys(plugins).forEach((name) => { + const plugin = plugins[name] try { - plugins[name].fn({ + plugin.fn({ ...DEFAULT_SCOPE, term, - hide: id => store.dispatch(hideElement(`${name}-${id}`)), + hide: (id) => store.dispatch(hideElement(`${name}-${id}`)), update: (id, result) => store.dispatch(updateElement(`${name}-${id}`, result)), - display: payload => display(name, payload), - settings: pluginSettings.getUserSettings(name) + display: (payload) => display(name, payload), + settings: pluginSettings.getUserSettings(plugin, name) }) } catch (error) { // Do not fail on plugin errors, just log them to console @@ -60,12 +61,11 @@ const eachPlugin = (term, display) => { }) } - /** * Handle results found by plugin * * @param {String} term Search term that was used for found results - * @param {Array or Object} result Found results (or result) + * @param {Array | Object} result Found results (or result) * @return {Object} redux action */ function onResultFound(term, result) { @@ -78,7 +78,6 @@ function onResultFound(term, result) { } } - /** * Action that clears everthing in search box * @@ -104,7 +103,7 @@ export function updateTerm(term) { }) eachPlugin(term, (plugin, payload) => { let result = Array.isArray(payload) ? payload : [payload] - result = result.map(x => ({ + result = result.map((x) => ({ ...x, plugin, // Scope result ids with plugin name and use title if id is empty @@ -121,8 +120,8 @@ export function updateTerm(term) { /** * Action to move highlighted cursor to next or prev element - * @param {Integer} diff 1 or -1 - * @return {Object} redux action + * @param {1 | -1} diff + * @return {Object} redux action */ export function moveCursor(diff) { return { @@ -133,8 +132,8 @@ export function moveCursor(diff) { /** * Action to change highlighted element - * @param {Integer} index of new highlighted element - * @return {Object} redux action + * @param {number} index Index of new highlighted element + * @return {Object} redux action */ export function selectElement(index) { return { @@ -146,7 +145,7 @@ export function selectElement(index) { /** * Action to remove element from results list by id * @param {String} id - * @return {Object} redux action + * @return {Object} redux action */ export function hideElement(id) { return { diff --git a/app/plugins/core/index.js b/app/plugins/core/index.js index 27139254..78ec4a21 100644 --- a/app/plugins/core/index.js +++ b/app/plugins/core/index.js @@ -5,4 +5,6 @@ import settings from './settings' import version from './version' import reload from './reload' -export default { autocomplete, quit, plugins, settings, version, reload } +export default { + autocomplete, quit, plugins, settings, version, reload +} diff --git a/app/plugins/externalPlugins.js b/app/plugins/externalPlugins.js index a3a13790..ecbcf5fe 100644 --- a/app/plugins/externalPlugins.js +++ b/app/plugins/externalPlugins.js @@ -1,7 +1,7 @@ import debounce from 'lodash/debounce' import chokidar from 'chokidar' import path from 'path' -import { initializePlugin } from 'lib/initializePlugins' +import initPlugin from 'lib/initPlugin' import { modulesDirectory, ensureFiles, settings } from 'lib/plugins' const requirePlugin = (pluginPath) => { @@ -99,8 +99,7 @@ pluginsWatcher.on('addDir', (pluginPath) => { }, 1000)) plugins[pluginName] = plugin if (!global.isBackground) { - console.log('Initialize async plugin', pluginName) - initializePlugin(pluginName) + initPlugin(plugin, pluginName) } console.groupEnd() }, 1000) diff --git a/jest.config.js b/jest.config.js index ae5b00b5..c70394f1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { + collectCoverage: true, moduleDirectories: ['node_modules', 'app'], moduleNameMapper: { '\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js',