diff --git a/bin/stencil-pull.js b/bin/stencil-pull.js index bb6b761a..685d3f5c 100755 --- a/bin/stencil-pull.js +++ b/bin/stencil-pull.js @@ -4,7 +4,7 @@ require('colors'); const { PACKAGE_INFO, API_HOST } = require('../constants'); const program = require('../lib/commander'); -const stencilPull = require('../lib/stencil-pull'); +const StencilPull = require('../lib/stencil-pull'); const { checkNodeVersion } = require('../lib/cliCommon'); const { printCliResultErrorAndExit } = require('../lib/cliCommon'); @@ -22,6 +22,7 @@ program 'specify the channel ID of the storefront to pull configuration from', parseInt, ) + .option('-a, --activate [variationname]', 'specify the variation of the theme to activate') .parse(process.argv); checkNodeVersion(); @@ -32,11 +33,8 @@ const options = { saveConfigName: cliOptions.filename, channelId: cliOptions.channel_id, saved: cliOptions.saved || false, - applyTheme: true, // fix to be compatible with stencil-push.utils + applyTheme: true, + activate: cliOptions.activate, }; -stencilPull(options, (err) => { - if (err) { - printCliResultErrorAndExit(err); - } -}); +new StencilPull().run(options).catch(printCliResultErrorAndExit); diff --git a/bin/stencil-start.js b/bin/stencil-start.js index 20338d10..0d2893d7 100755 --- a/bin/stencil-start.js +++ b/bin/stencil-start.js @@ -10,7 +10,7 @@ program .version(PACKAGE_INFO.version) .option('-o, --open', 'Automatically open default browser') .option('-v, --variation [name]', 'Set which theme variation to use while developing') - .option('-c, --channelId [channelId]', 'Set the channel id for the storefront') + .option('-c, --channelId [channelId]', 'Set the channel id for the storefront', parseInt) .option('--host [hostname]', 'specify the api host') .option( '--tunnel [name]', diff --git a/lib/stencil-pull.js b/lib/stencil-pull.js index d2a6cbdf..e03d5651 100644 --- a/lib/stencil-pull.js +++ b/lib/stencil-pull.js @@ -1,21 +1,208 @@ -const async = require('async'); -const stencilPushUtils = require('./stencil-push.utils'); -const stencilPullUtils = require('./stencil-pull.utils'); - -function stencilPull(options = {}, callback) { - async.waterfall( - [ - async.constant(options), - stencilPushUtils.readStencilConfigFile, - stencilPushUtils.getStoreHash, - stencilPushUtils.getChannels, - stencilPushUtils.promptUserForChannel, - stencilPullUtils.getChannelActiveTheme, - stencilPullUtils.getThemeConfiguration, - stencilPullUtils.mergeThemeConfiguration, - ], - callback, - ); +const fsModule = require('fs'); +const _ = require('lodash'); +const StencilConfigManager = require('./StencilConfigManager'); +const themeApiClientModule = require('./theme-api-client'); +const stencilPushUtilsModule = require('./stencil-push.utils'); +const fsUtilsModule = require('./utils/fsUtils'); + +require('colors'); + +class StencilPull { + constructor({ + stencilConfigManager = new StencilConfigManager(), + themeApiClient = themeApiClientModule, + stencilPushUtils = stencilPushUtilsModule, + fsUtils = fsUtilsModule, + fs = fsModule, + } = {}) { + this._stencilConfigManager = stencilConfigManager; + this._themeApiClient = themeApiClient; + this._stencilPushUtils = stencilPushUtils; + this._fsUtils = fsUtils; + this._fs = fs; + } + + /** + * @param {Object} cliOptions + */ + async run(cliOptions) { + const stencilConfig = await this._stencilConfigManager.read(); + const storeHash = await this._themeApiClient.getStoreHash({ + storeUrl: stencilConfig.normalStoreUrl, + }); + + let { channelId } = cliOptions; + if (!channelId) { + const channels = await this._themeApiClient.getStoreChannels({ + accessToken: stencilConfig.accessToken, + apiHost: cliOptions.apiHost, + storeHash, + }); + + channelId = await this._stencilPushUtils.promptUserToSelectChannel(channels); + } + + const activeTheme = await this.getActiveTheme({ + accessToken: stencilConfig.accessToken, + apiHost: cliOptions.apiHost, + storeHash, + channelId, + }); + + console.log('ok'.green + ` -- Fetched theme details for channel ${channelId}`); + + const variations = await this._themeApiClient.getVariationsByThemeId({ + accessToken: stencilConfig.accessToken, + apiHost: cliOptions.apiHost, + themeId: activeTheme.active_theme_uuid, + storeHash, + }); + + const variationId = this._stencilPushUtils.getActivatedVariation( + variations, + cliOptions.activate, + ); + + const remoteThemeConfiguration = await this.getThemeConfiguration({ + saved: cliOptions.saved, + activeTheme, + accessToken: stencilConfig.accessToken, + apiHost: cliOptions.apiHost, + storeHash, + variationId, + }); + + console.log( + 'ok'.green + ` -- Fetched ${cliOptions.saved ? 'saved' : 'active'} configuration`, + ); + + await this.mergeThemeConfiguration({ + variationId, + activate: cliOptions.activate, + remoteThemeConfiguration, + saveConfigName: cliOptions.saveConfigName, + }); + + return true; + } + + /** + * @param {Object} options + * @param {String} options.accessToken + * @param {String} options.apiHost + * @param {String} options.storeHash + * @param {Number} options.channelId + */ + async getActiveTheme({ accessToken, apiHost, storeHash, channelId }) { + const activeTheme = await this._themeApiClient.getChannelActiveTheme({ + accessToken, + apiHost, + storeHash, + channelId, + }); + + return activeTheme; + } + + /** + * @param {Object} options + * @param {Boolean} options.saved + * @param {Object} options.activeTheme + * @param {String} options.accessToken + * @param {String} options.apiHost + * @param {String} options.storeHash + * @param {String} options.variationId + */ + async getThemeConfiguration({ + saved, + activeTheme, + accessToken, + apiHost, + storeHash, + variationId, + }) { + const configurationId = saved + ? activeTheme.saved_theme_configuration_uuid + : activeTheme.active_theme_configuration_uuid; + + const remoteThemeConfiguration = await this._themeApiClient.getThemeConfiguration({ + accessToken, + apiHost, + storeHash, + themeId: activeTheme.active_theme_uuid, + configurationId, + variationId, + }); + + return remoteThemeConfiguration; + } + + /** + * @param {Object} options + * @param {String} options.variationId + * @param {String} options.activate + * @param {Object} options.remoteThemeConfiguration + * @param {Object} options.remoteThemeConfiguration + */ + async mergeThemeConfiguration({ + variationId, + activate, + remoteThemeConfiguration, + saveConfigName, + }) { + const localConfig = await this._fsUtils.parseJsonFile('config.json'); + let diffDetected = false; + let { settings } = localConfig; + + if (variationId) { + ({ settings } = localConfig.variations.find((v) => v.name === activate)); + } + + // For any keys the remote configuration has in common with the local configuration, + // overwrite the local configuration if the remote configuration differs + for (const [key, remoteVal] of Object.entries(remoteThemeConfiguration.settings)) { + if (!(key in settings)) { + continue; + } + const localVal = settings[key]; + + // Check for different types, and throw an error if they are found + if (typeof localVal !== typeof remoteVal) { + throw new Error( + `Theme configuration key "${key}" cannot be merged because it is not of the same type. ` + + `Remote configuration is of type ${typeof remoteVal} while local configuration is of type ${typeof localVal}.`, + ); + } + + // If a different value is found, overwrite the local config + if (!_.isEqual(localVal, remoteVal)) { + settings[key] = remoteVal; + diffDetected = true; + } + } + + // Does a file need to be written? + if (diffDetected || saveConfigName !== 'config.json') { + if (diffDetected) { + console.log( + 'ok'.green + ' -- Remote configuration merged with local configuration', + ); + } else { + console.log( + 'ok'.green + + ' -- Remote and local configurations are in sync for all common keys', + ); + } + + await this._fs.promises.writeFile(saveConfigName, JSON.stringify(localConfig, null, 2)); + console.log('ok'.green + ` -- Configuration written to ${saveConfigName}`); + } else { + console.log( + 'ok'.green + + ` -- Remote and local configurations are in sync for all common keys, no action taken`, + ); + } + } } -module.exports = stencilPull; +module.exports = StencilPull; diff --git a/lib/stencil-pull.spec.js b/lib/stencil-pull.spec.js new file mode 100644 index 00000000..f6bf6e06 --- /dev/null +++ b/lib/stencil-pull.spec.js @@ -0,0 +1,135 @@ +const stencilPushUtilsModule = require('./stencil-push.utils'); +const StencilPull = require('./stencil-pull'); + +afterAll(() => jest.restoreAllMocks()); + +describe('StencilStart unit tests', () => { + const accessToken = 'accessToken_value'; + const normalStoreUrl = 'https://www.example.com'; + const channelId = 1; + const storeHash = 'storeHash_value'; + const channels = [ + { + channel_id: channelId, + url: normalStoreUrl, + }, + ]; + const activeThemeUuid = 'activeThemeUuid_value'; + const variations = [ + { uuid: 1, name: 'Light' }, + { uuid: 2, name: 'Bold' }, + ]; + + const localThemeConfiguration = { + settings: {}, + variations: [ + { + name: 'Light', + settings: {}, + }, + ], + }; + const remoteThemeConfiguration = { + settings: { + 'body-font': 'Google_Source+Sans+Pro_400', + 'headings-font': 'Google_Roboto_400', + 'color-textBase': '#ffffff', + 'color-textBase--hover': '#bbbbbb', + }, + }; + + const saveConfigName = 'config.json'; + const cliOptions = { + channelId, + saveConfigName, + saved: false, + applyTheme: true, + }; + const stencilConfig = { + accessToken, + normalStoreUrl, + }; + + const getThemeApiClientStub = () => ({ + getStoreHash: jest.fn().mockResolvedValue(storeHash), + getStoreChannels: jest.fn().mockResolvedValue(channels), + getChannelActiveTheme: jest.fn().mockResolvedValue({ + active_theme_uuid: activeThemeUuid, + }), + getVariationsByThemeId: jest.fn().mockResolvedValue(variations), + getThemeConfiguration: jest.fn().mockResolvedValue(remoteThemeConfiguration), + }); + const getFsUtilsStub = () => ({ + parseJsonFile: jest.fn().mockResolvedValue(localThemeConfiguration), + }); + const getFsModuleStub = () => ({ + promises: { + writeFile: jest.fn(), + }, + }); + const getStencilConfigManagerStub = () => ({ + read: jest.fn().mockResolvedValue(stencilConfig), + }); + const getStencilPushUtilsStub = () => ({ + promptUserToSelectChannel: jest.fn(), + getActivatedVariation: stencilPushUtilsModule.getActivatedVariation, + }); + + const createStencilPullInstance = ({ + stencilConfigManager, + themeApiClient, + stencilPushUtils, + fsUtils, + fsModule, + } = {}) => { + const passedArgs = { + stencilConfigManager: stencilConfigManager || getStencilConfigManagerStub(), + themeApiClient: themeApiClient || getThemeApiClientStub(), + stencilPushUtils: stencilPushUtils || getStencilPushUtilsStub(), + fsUtils: fsUtils || getFsUtilsStub(), + fs: fsModule || getFsModuleStub(), + }; + const instance = new StencilPull(passedArgs); + + return { + passedArgs, + instance, + }; + }; + + describe('constructor', () => { + it('should create an instance of StencilPull without options parameters passed', () => { + const instance = new StencilPull(); + + expect(instance).toBeInstanceOf(StencilPull); + }); + + it('should create an instance of StencilStart with all options parameters passed', () => { + const { instance } = createStencilPullInstance(); + + expect(instance).toBeInstanceOf(StencilPull); + }); + }); + + describe('run', () => { + it('should run stencil pull with channel id', async () => { + const { instance } = createStencilPullInstance(); + + const result = await instance.run(cliOptions); + + expect(result).toBe(true); + }); + + it('should run stencil pull without channel id', async () => { + const themeApiClient = getThemeApiClientStub(); + const { instance } = createStencilPullInstance({ themeApiClient }); + + instance.run({ saveConfigName }); + + const result = await instance.run(cliOptions); + + expect(themeApiClient.getStoreChannels).toHaveBeenCalled(); + expect(result).toBe(true); + }); + }); +}); diff --git a/lib/stencil-pull.utils.js b/lib/stencil-pull.utils.js index f919be4d..5807d180 100644 --- a/lib/stencil-pull.utils.js +++ b/lib/stencil-pull.utils.js @@ -1,7 +1,4 @@ -const fs = require('fs'); -const _ = require('lodash'); const themeApiClient = require('./theme-api-client'); -const { parseJsonFile } = require('./utils/fsUtils'); const utils = {}; @@ -25,83 +22,4 @@ utils.getChannelActiveTheme = async (options) => { return { ...options, activeTheme }; }; -utils.getThemeConfiguration = async (options) => { - const { - config: { accessToken }, - apiHost, - storeHash, - activeTheme, - saved, - } = options; - - const themeId = activeTheme.active_theme_uuid; - - const configurationId = saved - ? activeTheme.saved_theme_configuration_uuid - : activeTheme.active_theme_configuration_uuid; - - const remoteThemeConfiguration = await themeApiClient.getThemeConfiguration({ - accessToken, - apiHost, - storeHash, - themeId, - configurationId, - }); - - console.log('ok'.green + ` -- Fetched ${saved ? 'saved' : 'active'} configuration`); - - return { ...options, remoteThemeConfiguration }; -}; - -utils.mergeThemeConfiguration = async (options) => { - const { remoteThemeConfiguration } = options; - - const localConfig = await parseJsonFile('config.json'); - let diffDetected = false; - - // For any keys the remote configuration has in common with the local configuration, - // overwrite the local configuration if the remote configuration differs - for (const [key, remoteVal] of Object.entries(remoteThemeConfiguration.settings)) { - if (!(key in localConfig.settings)) { - continue; - } - const localVal = localConfig.settings[key]; - - // Check for different types, and throw an error if they are found - if (typeof localVal !== typeof remoteVal) { - throw new Error( - `Theme configuration key "${key}" cannot be merged because it is not of the same type. ` + - `Remote configuration is of type ${typeof remoteVal} while local configuration is of type ${typeof localVal}.`, - ); - } - - // If a different value is found, overwrite the local config - if (!_.isEqual(localVal, remoteVal)) { - localConfig.settings[key] = remoteVal; - diffDetected = true; - } - } - - // Does a file need to be written? - if (diffDetected || options.saveConfigName !== 'config.json') { - if (diffDetected) { - console.log('ok'.green + ' -- Remote configuration merged with local configuration'); - } else { - console.log( - 'ok'.green + ' -- Remote and local configurations are in sync for all common keys', - ); - } - - await fs.promises.writeFile(options.saveConfigName, JSON.stringify(localConfig, null, 2)); - console.log('ok'.green + ` -- Configuration written to ${options.saveConfigName}`); - } else { - console.log( - 'ok'.green + - ` -- Remote and local configurations are in sync for all common keys, no action taken`, - ); - } - - return options; -}; - module.exports = utils; diff --git a/lib/stencil-push.utils.js b/lib/stencil-push.utils.js index 4ca8f569..e09bb8b7 100644 --- a/lib/stencil-push.utils.js +++ b/lib/stencil-push.utils.js @@ -310,9 +310,18 @@ utils.getVariations = async (options) => { storeHash, }); + const variationId = utils.getActivatedVariation(variations, activate); + if (!variationId) { + return { ...options, variations }; + } + + return { ...options, variationId }; +}; + +utils.getActivatedVariation = (variations, activate) => { // Activate the default variation if (activate === true) { - return { ...options, variationId: variations[0].uuid }; + return variations[0].uuid; } // Activate the specified variation if (activate !== undefined) { @@ -325,11 +334,11 @@ utils.getVariations = async (options) => { ); } - return { ...options, variationId: foundVariation.uuid }; + return foundVariation.uuid; } // Didn't specify a variation explicitly, will ask the user later - return { ...options, variations }; + return null; }; utils.promptUserForChannel = async (options) => { diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 9b1b78d7..f800e2fc 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -85,9 +85,7 @@ class StencilStart { ? cliOptions.channelId : await this._stencilPushUtils.promptUserToSelectChannel(channels); - const foundChannel = channels.find( - (channel) => channel.channel_id === parseInt(channelId, 10), - ); + const foundChannel = channels.find((channel) => channel.channel_id === channelId); return foundChannel ? foundChannel.url : null; } diff --git a/lib/theme-api-client.js b/lib/theme-api-client.js index 8b1ada8c..b8c96c4f 100644 --- a/lib/theme-api-client.js +++ b/lib/theme-api-client.js @@ -222,6 +222,7 @@ async function getStoreChannels({ accessToken, apiHost, storeHash }) { * @param {string} options.storeHash * @param {string} options.themeId * @param {string} options.configurationId + * @param {string} options.variationId * @returns {Promise} */ async function getThemeConfiguration({ @@ -230,10 +231,17 @@ async function getThemeConfiguration({ storeHash, themeId, configurationId, + variationId, }) { try { + let url = `${apiHost}/stores/${storeHash}/v3/themes/${themeId}/configurations`; + if (variationId) { + url += `?variation_uuid=${variationId}`; + } else { + url += `?uuid:in=${configurationId}`; + } const response = await networkUtils.sendApiRequest({ - url: `${apiHost}/stores/${storeHash}/v3/themes/${themeId}/configurations?uuid:in=${configurationId}`, + url, accessToken, }); // If configurations array is empty, the theme ID was valid but the configuration ID was not