diff --git a/bin/stencil-init b/bin/stencil-init index 4cd00d32..078126cf 100755 --- a/bin/stencil-init +++ b/bin/stencil-init @@ -1,10 +1,11 @@ #!/usr/bin/env node require('colors'); +const Program = require('commander'); +const _ = require('lodash'); + const StencilInit = require('../lib/stencil-init'); const pkg = require('../package.json'); -const Program = require('commander'); -const dotStencilFilePath = './.stencil'; const versionCheck = require('../lib/version-check'); Program @@ -18,4 +19,7 @@ if (!versionCheck()) { return; } -StencilInit.run(dotStencilFilePath, Program.url, Program.token, Program.port); +const dotStencilFilePath = './.stencil'; +const cliOptions = _.pick(Program, ['url', 'token', 'port']); + +new StencilInit().run(dotStencilFilePath, cliOptions); diff --git a/constants.js b/constants.js index 78742051..ac6f9f1b 100644 --- a/constants.js +++ b/constants.js @@ -2,4 +2,14 @@ const path = require('path'); const packagePath = path.join(process.cwd(), 'package.json'); const packageInfo = require(packagePath); -module.exports = { packageInfo }; +const DEFAULT_CUSTOM_LAYOUTS_CONFIG = { + 'brand': {}, + 'category': {}, + 'page': {}, + 'product': {}, +}; + +module.exports = { + packageInfo, + DEFAULT_CUSTOM_LAYOUTS_CONFIG, +}; diff --git a/lib/stencil-init.js b/lib/stencil-init.js index 6517824e..face3ad5 100644 --- a/lib/stencil-init.js +++ b/lib/stencil-init.js @@ -1,87 +1,144 @@ 'use strict'; -const Fs = require('fs'); -const Inquirer = require('inquirer'); +const fsModule = require('fs'); +const inquirerModule = require('inquirer'); -const jsonLint = require('./json-lint'); +const serverConfigModule = require('../server/config'); +const jsonLintModule = require('./json-lint'); +const { DEFAULT_CUSTOM_LAYOUTS_CONFIG } = require("../constants"); -async function performAnswers(stencilConfig, dotStencilFilePath, answers) { - const performedStencilConfig = { - customLayouts: { - 'brand': {}, - 'category': {}, - 'page': {}, - 'product': {}, - }, - ...stencilConfig, - ...answers, - }; +class StencilInit { + /** + * @param inquirer + * @param jsonLint + * @param fs + * @param serverConfig + * @param logger + */ + constructor ({ + inquirer = inquirerModule, + jsonLint = jsonLintModule, + fs = fsModule, + serverConfig = serverConfigModule, + logger = console, + } = {}) { + this.inquirer = inquirer; + this.jsonLint = jsonLint; + this.logger = logger; + this.fs = fs; + this.serverConfig = serverConfig; + } - Fs.writeFileSync(dotStencilFilePath, JSON.stringify(performedStencilConfig, null, 2)); -} + /** + * @param {string} dotStencilFilePath + * @param {object} cliOptions + * @param {string} cliOptions.url + * @param {string} cliOptions.token + * @param {number} cliOptions.port + * @returns {Promise} + */ + async run (dotStencilFilePath, cliOptions = {}) { + const oldStencilConfig = this.readStencilConfig(dotStencilFilePath); + const defaultAnswers = this.getDefaultAnswers(oldStencilConfig, cliOptions); + const answers = await this.askQuestions(defaultAnswers); + const updatedStencilConfig = this.applyAnswers(oldStencilConfig, answers); + this.saveStencilConfig(updatedStencilConfig, dotStencilFilePath); -async function run(dotStencilFilePath, url, token, port) { - let stencilConfig = {}; + this.logger.log('You are now ready to go! To start developing, run $ ' + 'stencil start'.cyan); + } - if (Fs.existsSync(dotStencilFilePath)) { - const dotStencilFile = Fs.readFileSync(dotStencilFilePath, { encoding: 'utf-8' }); - try { - stencilConfig = jsonLint.parse(dotStencilFile, dotStencilFilePath); - } catch (err) { - console.error( - 'Detected a broken .stencil file: ', - err, - '\nThe file will be rewritten with your answers', - ); + /** + * @param {string} dotStencilFilePath + * @returns {object} + */ + readStencilConfig (dotStencilFilePath) { + if (this.fs.existsSync(dotStencilFilePath)) { + const dotStencilFile = this.fs.readFileSync(dotStencilFilePath, { encoding: 'utf-8' }); + try { + // We use jsonLint.parse instead of JSON.parse because jsonLint throws errors with better explanations what is wrong + return this.jsonLint.parse(dotStencilFile, dotStencilFilePath); + } catch (err) { + this.logger.error( + 'Detected a broken .stencil file:\n', + err, + '\nThe file will be rewritten with your answers', + ); + } } + + return {}; } - const questions = [ - { - type: 'input', - name: 'normalStoreUrl', - message: 'What is the URL of your store\'s home page?', - validate: function (val) { - if (/^https?:\/\//.test(val)) { - return true; - } else { - return 'You must enter a URL'; - } + /** + * @param {{port: (number), normalStoreUrl: (string), accessToken: (string)}} stencilConfig + * @param {{port: (number), url: (string), token: (string)}} cliOptions + * @returns {{port: (number), normalStoreUrl: (string), accessToken: (string)}} + */ + getDefaultAnswers (stencilConfig, cliOptions) { + return { + normalStoreUrl: cliOptions.url || stencilConfig.normalStoreUrl, + accessToken: cliOptions.token || stencilConfig.accessToken, + port: cliOptions.port || stencilConfig.port || this.serverConfig.get('/server/port'), + }; + } + + /** + * @param {{port: (number), normalStoreUrl: (string), accessToken: (string)}} defaultAnswers + * @returns {Promise} + */ + async askQuestions (defaultAnswers) { + return await this.inquirer.prompt([ + { + type: 'input', + name: 'normalStoreUrl', + message: 'What is the URL of your store\'s home page?', + validate: val => /^https?:\/\//.test(val) || 'You must enter a URL', + default: defaultAnswers.normalStoreUrl, }, - default: url || stencilConfig.normalStoreUrl, - }, - { - type: 'input', - name: 'accessToken', - message: 'What is your Stencil OAuth Access Token?', - default: token || stencilConfig.accessToken, - filter: function(val) { - return val.trim(); + { + type: 'input', + name: 'accessToken', + message: 'What is your Stencil OAuth Access Token?', + default: defaultAnswers.accessToken, + filter: val => val.trim(), }, - }, - { - type: 'input', - name: 'port', - message: 'What port would you like to run the server on?', - default: port || stencilConfig.port || 3000, - validate: function (val) { - if (isNaN(val)) { - return 'You must enter an integer'; - } else if (val < 1024 || val > 65535) { - return 'The port number must be between 1025 and 65535'; - } else { - return true; - } + { + type: 'input', + name: 'port', + message: 'What port would you like to run the server on?', + default: defaultAnswers.port, + validate: val => { + if (isNaN(val)) { + return 'You must enter an integer'; + } else if (val < 1024 || val > 65535) { + return 'The port number must be between 1025 and 65535'; + } else { + return true; + } + }, }, - }, - ]; - const answers = await Inquirer.prompt(questions); + ]); + } - await performAnswers(stencilConfig, dotStencilFilePath, answers); + /** + * @param {object} stencilConfig + * @param {object} answers + * @returns {object} + */ + applyAnswers (stencilConfig, answers) { + return { + customLayouts: DEFAULT_CUSTOM_LAYOUTS_CONFIG, + ...stencilConfig, + ...answers, + }; + } - console.log('You are now ready to go! To start developing, run $ ' + 'stencil start'.cyan); + /** + * @param {object} config + * @param {string} path + */ + saveStencilConfig (config, path) { + this.fs.writeFileSync(path, JSON.stringify(config, null, 2)); + } } -module.exports = { - performAnswers, - run, -}; +module.exports = StencilInit; diff --git a/lib/stencil-init.spec.js b/lib/stencil-init.spec.js index 4346fb9d..a9b391b4 100644 --- a/lib/stencil-init.spec.js +++ b/lib/stencil-init.spec.js @@ -1,52 +1,343 @@ 'use strict'; +const _ = require('lodash'); const Code = require('code'); const Sinon = require('sinon'); const Lab = require('@hapi/lab'); -const Fs = require('fs'); -const lab = exports.lab = Lab.script(); -const describe = lab.describe; -const Inquirer = require('inquirer'); -const expect = Code.expect; -const it = lab.it; +const fs = require('fs'); +const inquirer = require('inquirer'); + +const jsonLint = require('./json-lint'); +const serverConfig = require('../server/config'); const StencilInit = require('./stencil-init'); +const { DEFAULT_CUSTOM_LAYOUTS_CONFIG } = require('../constants'); +const { assertNoMutations } = require('../test/assertions/assertNoMutations'); -describe('stencil init', () => { - let sandbox; - let consoleErrorStub; - let inquirerPromptStub; +const { afterEach, beforeEach, describe, it } = exports.lab = Lab.script(); +const { expect } = Code; - lab.beforeEach(() => { - sandbox = Sinon.createSandbox(); +const getStencilConfig = () => ({ + customLayouts: { + brand: { + a: 'aaaa', + }, + category: {}, + page: { + b: 'bbbb', + }, + product: {}, + }, + normalStoreUrl: "https://url-from-stencilConfig.mybigcommerce.com", + port: 3001, + accessToken: "accessToken_from_stencilConfig", + githubToken: "githubToken_1234567890", +}); +const getAnswers = () => ({ + normalStoreUrl: "https://url-from-answers.mybigcommerce.com", + port: 3003, + accessToken: "accessToken_from_answers", +}); +const getCliOptions = () => ({ + url: "https://url-from-cli-options.mybigcommerce.com", + port: 3002, + token: "accessToken_from_CLI_options", +}); - sandbox.stub(console, 'log'); - consoleErrorStub = sandbox.stub(console, 'error'); +afterEach(() => Sinon.restore()); - inquirerPromptStub = sandbox.stub(Inquirer, 'prompt'); - inquirerPromptStub.returns({}); +describe('StencilInit integration tests:', () => { + describe('run', async () => { + it('should perform all the actions, save the result and inform the user about the successful finish', async () => { + const dotStencilFilePath = './test/_mocks/bin/dotStencilFile.json'; + const answers = getAnswers(); + const expectedResult = JSON.stringify({ customLayouts: DEFAULT_CUSTOM_LAYOUTS_CONFIG, ...answers }, null, 2); + const fsWriteFileSyncStub = Sinon.stub(fs, "writeFileSync"); + const inquirerPromptStub = Sinon.stub(inquirer, 'prompt').returns(answers); + const consoleErrorStub = Sinon.stub(console, 'error'); + const consoleLogStub = Sinon.stub(console, 'log'); - sandbox.stub(Fs, 'writeFileSync'); + // Test with real entities, just some methods stubbed + const instance = new StencilInit({ + inquirer, + jsonLint, + fs, + serverConfig, + logger: console, + }); + await instance.run(dotStencilFilePath, getCliOptions()); + + expect(fsWriteFileSyncStub.calledOnce).to.be.true(); + expect(inquirerPromptStub.calledOnce).to.be.true(); + expect(consoleErrorStub.calledOnce).to.be.false(); + expect(consoleLogStub.calledOnce).to.be.true(); + + expect(fsWriteFileSyncStub.lastCall.args).to.equal([dotStencilFilePath, expectedResult]); + expect(consoleLogStub.calledWith('You are now ready to go! To start developing, run $ ' + 'stencil start'.cyan)).to.be.true(); + }); }); +}); + +describe('StencilInit unit tests:', () => { + const serverConfigPort = 3000; + const dotStencilFilePath = '/some/test/path/dotStencilFile.json'; + let consoleStub; + let inquirerStub; + let jsonLintStub; + let fsStub; + let serverConfigStub; + let getStencilInitInstance; + + beforeEach(() => { + consoleStub = { + log: Sinon.stub(), + error: Sinon.stub(), + }; + inquirerStub = { + prompt: Sinon.stub().returns(getAnswers()), + }; + jsonLintStub = { + parse: Sinon.stub().returns(getStencilConfig()), + }; + fsStub = { + existsSync: Sinon.stub(), + readFileSync: Sinon.stub(), + writeFileSync: Sinon.stub(), + }; + serverConfigStub = { + get: Sinon.stub().callsFake(prop => { + return ({ + '/server/port': serverConfigPort, + })[prop]; + }), + }; + + getStencilInitInstance = () => new StencilInit({ + inquirer: inquirerStub, + jsonLint: jsonLintStub, + fs: fsStub, + serverConfig: serverConfigStub, + logger: consoleStub, + }); + }); + + describe('constructor', () => { + it('should create an instance of StencilInit without options parameters passed', async () => { + const instance = new StencilInit(); + + expect(instance).to.be.instanceOf(StencilInit); + }); - lab.afterEach(() => { - sandbox.restore(); + it('should create an instance of StencilInit with options parameters passed', async () => { + const instance = getStencilInitInstance(); + + expect(instance).to.be.instanceOf(StencilInit); + }); }); - it('Should call prompt on run and not log errors if the .stencil file is valid', async () => { - const dotStencilFilePath = './test/_mocks/bin/dotStencilFile.json'; + describe('readStencilConfig ', async () => { + it('should return an empty config if the file doesn\'t exist', async () => { + const instance = getStencilInitInstance(); + fsStub.existsSync.returns(false); + + const res = instance.readStencilConfig(dotStencilFilePath); + + expect(fsStub.existsSync.calledOnce).to.be.true(); + expect(res).to.equal({}); + }); + + it('should read the file and return parsed results if the file exists and it is valid', async () => { + const parsedConfig = getStencilConfig(); + const serializedConfig = JSON.stringify(parsedConfig, null, 2); + const instance = getStencilInitInstance(); + fsStub.existsSync.returns(true); + fsStub.readFileSync.returns(serializedConfig); + jsonLintStub.parse.returns(parsedConfig); + + const res = instance.readStencilConfig(dotStencilFilePath); + + expect(fsStub.existsSync.calledOnce).to.be.true(); + expect(fsStub.readFileSync.calledOnce).to.be.true(); + expect(jsonLintStub.parse.calledOnce).to.be.true(); + expect(consoleStub.error.calledOnce).to.be.false(); + + expect(fsStub.existsSync.calledWith(dotStencilFilePath)).to.be.true(); + expect(fsStub.readFileSync.calledWith(dotStencilFilePath, { encoding: 'utf-8' })).to.be.true(); + expect(jsonLintStub.parse.calledWith(serializedConfig, dotStencilFilePath)).to.be.true(); + + expect(res).to.equal(parsedConfig); + }); + + it('should read the file, inform the user that the file is broken and return an empty config', async () => { + const serializedConfig = '{ I am broken! }'; + const thrownError = new Error('invalid file'); + const instance = getStencilInitInstance(); + fsStub.existsSync.returns(true); + fsStub.readFileSync.returns(serializedConfig); + jsonLintStub.parse.throws(thrownError); + + const res = instance.readStencilConfig(dotStencilFilePath); + + expect(fsStub.existsSync.calledOnce).to.be.true(); + expect(fsStub.readFileSync.calledOnce).to.be.true(); + expect(jsonLintStub.parse.calledOnce).to.be.true(); + expect(consoleStub.error.calledOnce).to.be.true(); + + expect(fsStub.existsSync.calledWith(dotStencilFilePath)).to.be.true(); + expect(fsStub.readFileSync.calledWith(dotStencilFilePath, { encoding: 'utf-8' })).to.be.true(); + expect(jsonLintStub.parse.calledWith(serializedConfig, dotStencilFilePath)).to.be.true(); + + expect(res).to.equal({}); + }); + }); + + describe('getDefaultAnswers', async () => { + it('should not mutate the passed objects', async () => { + const stencilConfig = getStencilConfig(); + const cliOptions = getCliOptions(); + const instance = getStencilInitInstance(); + + await assertNoMutations( + [stencilConfig, cliOptions], + () => instance.getDefaultAnswers(stencilConfig, cliOptions), + ); + }); + + it('should pick values from cliOptions first if present', async () => { + const stencilConfig = getStencilConfig(); + const cliOptions = getCliOptions(); + const instance = getStencilInitInstance(); + + const res = instance.getDefaultAnswers(stencilConfig, cliOptions); + + expect(res.normalStoreUrl).to.equal(cliOptions.url); + expect(res.accessToken).to.equal(cliOptions.token); + expect(res.port).to.equal(cliOptions.port); + }); + + it('should pick values from stencilConfig if cliOptions are empty', async () => { + const stencilConfig = getStencilConfig(); + const cliOptions = {}; + const instance = getStencilInitInstance(); + + const res = instance.getDefaultAnswers(stencilConfig, cliOptions); + + expect(res.normalStoreUrl).to.equal(stencilConfig.normalStoreUrl); + expect(res.accessToken).to.equal(stencilConfig.accessToken); + expect(res.port).to.equal(stencilConfig.port); + }); + + it('should pick values from serverConfig if stencilConfig and cliOptions are empty', async () => { + const cliOptions = _.pick(getCliOptions(), ['url']); + const stencilConfig = _.pick(getStencilConfig(), ['accessToken']); + const instance = getStencilInitInstance(); + + const res = instance.getDefaultAnswers(stencilConfig, cliOptions); + + expect(res.port).to.equal(serverConfigPort); + + expect(res.normalStoreUrl).to.equal(cliOptions.url); + expect(res.accessToken).to.equal(stencilConfig.accessToken); + }); + }); + + describe('askQuestions', async () => { + it('should call inquirer.prompt with correct arguments', async () => { + const defaultAnswers = getAnswers(); + const instance = getStencilInitInstance(); + + await instance.askQuestions(defaultAnswers); + + expect(inquirerStub.prompt.calledOnce).to.be.true(); + // We compare the serialized results because the objects contain functions which hinders direct comparison + expect(JSON.stringify(inquirerStub.prompt.lastCall.args)).to.equal(JSON.stringify([[ + { + type: 'input', + name: 'normalStoreUrl', + message: 'What is the URL of your store\'s home page?', + validate(val) { + return /^https?:\/\//.test(val) + ? true + : 'You must enter a URL'; + }, + default: defaultAnswers.normalStoreUrl, + }, + { + type: 'input', + name: 'accessToken', + message: 'What is your Stencil OAuth Access Token?', + default: defaultAnswers.accessToken, + filter: val => val.trim(), + }, + { + type: 'input', + name: 'port', + message: 'What port would you like to run the server on?', + default: defaultAnswers.port, + validate: val => { + if (isNaN(val)) { + return 'You must enter an integer'; + } else if (val < 1024 || val > 65535) { + return 'The port number must be between 1025 and 65535'; + } else { + return true; + } + }, + }, + ]])); + }); + }); + + describe('applyAnswers', async () => { + it('should not mutate the passed objects', async () => { + const stencilConfig = getStencilConfig(); + const answers = getAnswers(); + const instance = getStencilInitInstance(); + + await assertNoMutations( + [stencilConfig, answers], + () => instance.applyAnswers(stencilConfig, answers), + ); + }); + + it('should correctly merge values from the passed objects', async () => { + const stencilConfig = getStencilConfig(); + const answers = getAnswers(); + const instance = getStencilInitInstance(); + + const res = instance.applyAnswers(stencilConfig, answers); + + expect(res.normalStoreUrl).to.equal(answers.normalStoreUrl); + expect(res.accessToken).to.equal(answers.accessToken); + expect(res.port).to.equal(answers.port); + + expect(res.githubToken).to.equal(stencilConfig.githubToken); + expect(res.customLayouts).to.equal(stencilConfig.customLayouts); + }); + + it('should add a customLayouts property with default empty values if it\'s absent in stencilConfig', async () => { + const stencilConfig = _.omit(getStencilConfig(), 'customLayouts'); + const answers = getAnswers(); + const instance = getStencilInitInstance(); - await StencilInit.run(dotStencilFilePath); + const res = instance.applyAnswers(stencilConfig, answers); - expect(consoleErrorStub.calledOnce).to.be.false(); - expect(inquirerPromptStub.calledOnce).to.be.true(); + expect(res.customLayouts).to.equal(DEFAULT_CUSTOM_LAYOUTS_CONFIG); + // Make sure that other props aren't overwritten: + expect(res.accessToken).to.equal(answers.accessToken); + expect(res.githubToken).to.equal(stencilConfig.githubToken); + }); }); - it('Should inform the user if the .stencil file is broken but continue running', async () => { - const dotStencilFilePath = './test/_mocks/malformedSchema.json'; + describe('saveStencilConfig ', async () => { + it('should call fs.writeFileSync with the serialized config', async () => { + const stencilConfig = getStencilConfig(); + const serializedConfig = JSON.stringify(stencilConfig, null, 2); + const instance = getStencilInitInstance(); - await StencilInit.run(dotStencilFilePath); + instance.saveStencilConfig(stencilConfig, dotStencilFilePath); - expect(consoleErrorStub.calledOnce).to.be.true(); - expect(inquirerPromptStub.calledOnce).to.be.true(); + expect(fsStub.writeFileSync.calledOnce).to.be.true(); + expect(fsStub.writeFileSync.calledWith(dotStencilFilePath, serializedConfig)).to.be.true(); + }); }); }); diff --git a/test/assertions/assertNoMutations.js b/test/assertions/assertNoMutations.js new file mode 100644 index 00000000..c2c42a94 --- /dev/null +++ b/test/assertions/assertNoMutations.js @@ -0,0 +1,24 @@ +const Code = require('code'); + +const { expect} = Code; + + +/** Asserts that all the passed entities weren't mutated after executing the passed procedure + * + * @param {object[]} entities + * @param {Function} procedure + * @returns {Promise} + */ +async function assertNoMutations (entities, procedure) { + const entitiesBefore = entities.map(entity => JSON.stringify(entity)); + + await procedure(); + + entities.forEach((entity, i) => { + expect(entitiesBefore[i]).to.equal(JSON.stringify(entity)); + }); +} + +module.exports = { + assertNoMutations, +};