diff --git a/docs/test/unit_test.md b/docs/test/unit_test.md new file mode 100644 index 0000000..4606e96 --- /dev/null +++ b/docs/test/unit_test.md @@ -0,0 +1,80 @@ +# Unit tests for warlock + +Unit tests will be provided over time for all major warlock functions, with continuous integration +as new commits are added. Unit tests use mocha and should.js, with all test scripts written in +coffeescript. + + +## Running unit tests + +To run the unit tests from a newly cloned repository + +```sh +npm install -g mocha +npm install +mocha +``` + + +## Writing new unit tests +All newly contributed functions of warlock should have unit tests created. The warlock team recognise +that most open source developers would prefer to write code than to write tests, so as a general rule +the aim is to provide maximum coverage with the minimum number of test cases, and to provide +test cases that don't require ongoing maintenance when minor changes to the API or logic occur. This +will allow future contributors to minimise the amount of testing code that needs modification for any +given extension to the logic. + +Test cases generally fall into two categories: synchronous cases and asynchronous cases. + +Synchronous cases are easier to write. The general pattern is: + +```coffeescript +'use strict' +should = require 'should' +config = require '../lib/warlock/config' + +describe 'warlock config: ', -> + describe 'config: ', -> + it 'add a value to the loaded config, it\'s still there when asked for again', -> + config( 'blah.four', 'four' ).should.eql 'four' + config( 'blah.four' ).should.eql 'four' +``` + +By preference we use eql rather than equal, as this verifies that the content of an object +matches rather than that the instance matches, and is generally good practice. + +Describe lines always end with a :, and the outermost describe should provide some indication +of the coffeescript file that is being tested - this is to allow a future developer to quickly +ascertain where they can find a given test that is failing. + +It lines should describe what it is that you're testing and what the expected result is, again +so that a future developer can quickly understand what a failing test was intending to prove. + +Asynchronous cases leverage mocha's ability to accept a "done()" callback once asynchronous processing +is complete. This requires both ( done ) on the 'it' line and an explicit invocation of done() at +the end of the test case. + +Unfortunately at time of writing there was an outstanding issue with mocha and should where failed +expectations in an asynchronous test case result in a timeout rather than an error message - refer: + https://github.com/visionmedia/mocha/pull/278 + https://github.com/visionmedia/mocha/pull/985 + +This can be worked around by enclosing the expectations in a try-catch block, with the catch propogating +the exception to the done call. + +The basic pattern is therefore: + +```coffeescript +describe 'warlock config: ', -> + describe 'config: ', -> + it 'get values from config that has been loaded', ( done ) -> + bootstrap._readConfig 'test/fixtures/config/user.json', 'test/fixtures/config/package.json' + .then -> + try + config( 'blah.one' ).should.eql true + + done() + catch err + done( err ) +``` + diff --git a/lib/warlock/bootstrap.coffee b/lib/warlock/bootstrap.coffee index b2118f5..bb4d20c 100644 --- a/lib/warlock/bootstrap.coffee +++ b/lib/warlock/bootstrap.coffee @@ -6,24 +6,45 @@ PATH = require 'path' # ngbo libraries warlock = require './../warlock' -module.exports = ( options ) -> + +bootstrap = module.exports = ( options ) -> warlock.debug.log "Bootstrapping warlock..." # Store the options provided for system-wide access warlock.options.init options + + configPath = FINDUP warlock.options( 'configPath' ), { cwd: process.cwd() } + pkgPath = FINDUP "package.json", { cwd: process.cwd() } + + bootstrap._readConfig( configPath, pkgPath ) + .then () -> + bootstrap._loadPlugins + .then () -> + bootstrap._setDefaultTask + + warlock.util.q true + + +### +# Find the warlock configuration and package.json, using defaults as necessary, +# read them in, and initialise the configuration using them +### +bootstrap._readConfig = ( configPath, pkgPath, promise ) -> # Locate the configuration file and save its path for later use - configPath = FINDUP warlock.options( 'configPath' ), { cwd: process.cwd() } promise = warlock.util.q "{}" + if not configPath? warlock.log.warning "No config file found. Using empty configuration instead." else warlock.debug.log "Config found: #{configPath}" promise = warlock.file.readFile( configPath ) - promise + return promise .then ( file ) -> # Save the config file location for later use warlock.options 'configFile', configPath || 'warlock.json' + + # TODO (pml): is it always true that the projectPath is the dir part of configPath? warlock.options 'projectPath', PATH.dirname( configPath ) # Parse its contents @@ -31,12 +52,11 @@ module.exports = ( options ) -> , ( err ) -> warlock.fatal "Could not read config file: #{err.toString()}." .then ( config ) -> - warlock.debug.log "Config loaded." + warlock.debug.log "Config loaded, content was:\n" + JSON.stringify( config ) # Load the config into warlock warlock.config.init config # Read in the package.json. It's required. - pkgPath = FINDUP "package.json", { cwd: process.cwd() } warlock.file.readFile( pkgPath ) , ( err ) -> warlock.fatal "Could not parse config file: #{err.toString()}" @@ -48,11 +68,19 @@ module.exports = ( options ) -> .then ( pkg ) -> warlock.debug.log "package.json loaded to `pkg`." warlock.config "pkg", pkg - - # Load all the plugins - warlock.plugins.load() , ( err ) -> warlock.fatal "Could not parse package.json: #{err.toString()}" + + + +### +# Load all plugins +### +bootstrap._loadPlugins = () -> + # Load all the plugins + warlock.debug.log "commencing loading plugins" + + warlock.plugins.load() .then () -> warlock.flow.validate() flows = warlock.flow.all() @@ -92,14 +120,18 @@ module.exports = ( options ) -> warlock.util.mout.object.forOwn metaTasks, ( deps, task ) -> warlock.task.add task, deps + warlock.debug.log "plugins loaded" + warlock.util.q true - .then () -> - # Ensure we have a default task defined, if none has been specified elsewhere - tasks = warlock.config( "default" ) - if tasks? and tasks?.length - warlock.task.add "default", tasks - else - warlock.log.warning "There is no default task defined. This is usually handed by plugins or spells." - warlock.util.q true + +bootstrap._setDefaultTask = () -> + # Set the default task(s) + tasks = warlock.config( "default" ) + + if tasks? and tasks?.length + warlock.task.add "default", tasks + else + warlock.log.warning "There is no default task defined. This is usually handed by plugins or spells." + diff --git a/lib/warlock/config.coffee b/lib/warlock/config.coffee index e67349e..4e7983d 100644 --- a/lib/warlock/config.coffee +++ b/lib/warlock/config.coffee @@ -21,7 +21,7 @@ _defaultConfig = plugins: [] # Tasks to prevent from running. - prevent: [ 'something1' ] + prevent: [] # Tasks to inject into one of the flows. inject: [] @@ -29,8 +29,6 @@ _defaultConfig = # The default tasks to run when none are specified. default: [] - myval: "Hello!" - ### # The current warlock-wide configuration. ### @@ -41,6 +39,17 @@ _config = {} ### _userConfig = {} + +### +# A multi-purpose function, allows merging config into the cached config, setting +# a key to a given value, or retrieving a value given a key +# +# key should always be provided. If val is provided, then key is set to val +# overwriting any previous value. If merge is true, then val is merged into the config, +# being added to any array or object already in the config at that key. +# +# In all instances, the value associated with the requested key is returned. +### config = module.exports = ( key, val, merge ) -> if key if val @@ -61,7 +70,10 @@ config.init = ( conf ) -> _config = warlock.util.merge {}, _defaultConfig ### -# Process every property of an object recursively as a template. +# Process every property of an object recursively as a template, if the +# attribute is a string, and in the format <%= xxxxx %> then get the value +# of xxx from the config, and substitute it in to the resulting object. +# # Ripped from Grunt nearly directly. ### propStringTmplRe = /^<%=\s*([a-z0-9_$]+(?:\.[a-z0-9_$]+)*)\s*%>$/i @@ -110,7 +122,7 @@ config.getRaw = ( key ) -> if key? then MOUT.object.get( _getCombinedConfig(), key ) else _getCombinedConfig() ### -# The user's configuration, containing only those variables set of manipulated by the user. All of +# The user's configuration, containing only those variables set or manipulated by the user. All of # these are also in _config, but this is what should be synchronized to warlock.json. ### diff --git a/lib/warlock/util.coffee b/lib/warlock/util.coffee index 84cc2f3..4c777e9 100644 --- a/lib/warlock/util.coffee +++ b/lib/warlock/util.coffee @@ -124,7 +124,8 @@ util.forEveryProperty = ( value, fn, fnContinue ) -> obj = {} MOUT.object.forIn value, ( val, key ) -> obj[ key ] = util.forEveryProperty val, fn, fnContinue - + return true + obj else # Otherwise pass value into fn and return. diff --git a/package.json b/package.json index 718c73d..442ffa0 100644 --- a/package.json +++ b/package.json @@ -43,5 +43,9 @@ "q": "~0.9.7", "rimraf": "~2.2.6", "vinyl-fs": "0.0.2" + }, + "devDependencies": { + "mocha": "^1.18.2", + "should": "^3.3.1" } } diff --git a/test/bootstrap.spec.coffee b/test/bootstrap.spec.coffee new file mode 100644 index 0000000..fe82fcd --- /dev/null +++ b/test/bootstrap.spec.coffee @@ -0,0 +1,62 @@ +'use strict' +should = require 'should' +join = ( require 'path' ).join +options = require '../lib/warlock/options' +warlock = require '../lib/warlock' +bootstrap = require '../lib/warlock/bootstrap' +config = require '../lib/warlock/config' + +require 'mocha' + +beforeEach -> + options.init { 'debug': false } + +describe 'warlock bootstrap: ', -> + describe 'read_config ', -> + # these tests focus on the fact that config routines are called, detailed testing of config is in the config module + it 'config, empty package.json, loaded correctly', ( done )-> + bootstrap._readConfig 'test/fixtures/bootstrap/user.json', 'test/fixtures/bootstrap/empty.json' + .then -> + try + config.get( 'standard_values.one' ).should.eql false + config.get( 'standard_values.extra_1' ).should.eql 1 + config.get( 'standard_values.extra_2' ).should.eql 'fred' + done() + catch err + done( err ) + + + it 'empty config, package.json, loaded correctly', ( done ) -> + bootstrap._readConfig 'test/fixtures/bootstrap/empty.json', 'test/fixtures/bootstrap/package.json' + .then -> + try + config.get( 'pkg.standard_values.one' ).should.eql true + config.get( 'pkg.standard_values.two' ).should.eql false + config.get( 'pkg.standard_values.three' ).should.eql "three" + config.get( 'pkg.structures.a_hash' ).should.eql { "value_1": "value", "value_2": true, "value_3": 3 } + config.get( 'pkg.structures.an_array' ).should.eql ["test", 1, true, "john"] + done() + catch err + done( err ) + + # TODO(pml): not clear how to test exceptions when asynch - mocha doesn't have great support + # ideally we'd test a file that cannot parse, a missing file etc + + + describe 'loadPlugins ', -> + # these tests focus on the fact that load plugins is called, detailed testing of plugins is in the plugin module + # TODO(pml): still need to work out what this actually does so as to verify it + + describe 'setDefaultTask', -> + it 'establishes default tasks', ( done ) -> + try + config.set( 'default', [ 'a_task' ] ) + + bootstrap._setDefaultTask() + done() + catch err + done( err ) + + # TODO(pml): this doesn't work - task setup needs more content + # warlock.task.getTasks().should.eql( [ 'a_task' ] ) + diff --git a/test/config.spec.coffee b/test/config.spec.coffee new file mode 100644 index 0000000..1729ae0 --- /dev/null +++ b/test/config.spec.coffee @@ -0,0 +1,193 @@ +'use strict' +should = require 'should' +join = ( require 'path' ).join +options = require '../lib/warlock/options' +warlock = require '../lib/warlock' +bootstrap = require '../lib/warlock/bootstrap' +config = require '../lib/warlock/config' + +require 'mocha' + +beforeEach -> + options.init { 'debug': false } + config.init( {} ) + +describe 'warlock config: ', -> + describe 'config: ', -> + it 'get values from config that has been loaded', ( done ) -> + bootstrap._readConfig 'test/fixtures/config/user.json', 'test/fixtures/config/package.json' + .then -> + try + config( 'blah.one' ).should.eql true + config( 'blah.two' ).should.eql false + config( 'blah.three' ).should.eql 'three' + config( 'blah.an_array' ).should.eql [ 'item_1', 'item_3' ] + config( 'blah.an_object' ).should.eql { "value_1": 1, "value_2": 'two' } + config( 'blah' ).should.eql { "one": true, "two": false, "three": "three", "an_array": [ 'item_1', 'item_3' ], "an_object": { "value_1": 1, "value_2": 'two' } } + + config( 'pkg.standard_values.one' ).should.eql true + config( 'pkg.standard_values.two' ).should.eql false + config( 'pkg.standard_values.three' ).should.eql "three" + config( 'pkg.structures.a_hash' ).should.eql { "value_1": "value", "value_2": true, "value_3": 3 } + config( 'pkg.structures.an_array' ).should.eql ["test", 1, true, "john"] + + done() + catch err + done( err ) + + it 'add a value to the loaded config, it\'s still there when asked for again', -> + config( 'blah.four', 'four' ).should.eql 'four' + config( 'blah.four' ).should.eql 'four' + + it 'add an an array value then override it, it\'s still there when asked for again', -> + config( 'blah.a_new_array', [ 'item_1', 'item_3' ], false ).should.eql [ 'item_1', 'item_3' ] + config( 'blah.a_new_array', [ 'item_2' ], false ).should.eql [ 'item_2' ] + config( 'blah.a_new_array' ).should.eql [ 'item_2' ] + + it 'use merge to add a value to an array, values have been persisted into the config', -> + config( 'blah.a_new_array', [ 'item_1', 'item_3' ], false ).should.eql [ 'item_1', 'item_3' ] + config( 'blah.a_new_array', [ 'item_2' ], true ).should.eql [ 'item_1', 'item_3', 'item_2' ] + config( 'blah.a_new_array' ).should.eql [ 'item_1', 'item_3', 'item_2' ] + + it 'add an object then override it, it\'s still there when asked for again', -> + config( 'blah.a_new_object', { value_1: 1, value_2: 'two', }, false ).should.eql { value_1: 1, value_2: 'two', } + config( 'blah.a_new_object', { 'value_3': 'three' }, false ).should.eql { value_3: 'three' } + config( 'blah.a_new_object' ).should.eql { value_3: 'three' } + + it 'use merge to add a value to an object in the loaded config, values have been persisted into the config', -> + config( 'blah.a_new_object', { value_1: 1, value_2: 'two', }, false ).should.eql { value_1: 1, value_2: 'two', } + config( 'blah.a_new_object', { 'value_3': 'three' }, true ).should.eql { value_1: 1, value_2: 'two', value_3: 'three' } + config( 'blah.a_new_object' ).should.eql { value_1: 1, value_2: 'two', value_3: 'three' } + + + describe 'config.init: ', -> + it 'init works', -> + # this test will need updating whenever the default structure is changed + conf = { value_1: 'one' } + config.init( conf ) + config.getRaw().should.eql { + globs: {} + paths: {} + plugins: [] + prevent: [] + inject: [] + default: [] + value_1: 'one' + } + + describe 'config.process: ', -> + it 'an object with no templates returns that same object', -> + obj = { + value_1: 'one' + value_2: 1, + value_3: false, + array: [ 'two', 2, true, { item: 'item_value' } ], + object: { attr1: 'attr', attr2: [ 1, 2, 3, 'string' ], attr3: false } + } + + config.process( obj ).should.eql( obj ) + + it 'an object with a template returns that same object with the template value replaced', -> + test_obj = { + value_1: '<%= blah.three %>' + value_2: 1, + value_3: false, + array: [ 'two', 2, true, { item: 'item_value' } ], + object: { attr1: 'attr', attr2: [ 1, 2, 3, 'string' ], attr3: false } + } + + result_obj = { + value_1: 'three' + value_2: 1, + value_3: false, + array: [ 'two', 2, true, { item: 'item_value' } ], + object: { attr1: 'attr', attr2: [ 1, 2, 3, 'string' ], attr3: false } + } + + config( 'blah.three', 'three' ) + config.process( test_obj ).should.eql( result_obj ) + + it 'mix of template values, including some that refer to arrays and objects', -> + test_obj = { + value_1: '<%= blah.three %>' + value_2: 1, + value_3: false, + array: [ 'two', 2, true, { item: '<%= blah.two %>' } ], + object: { attr1: 'attr', attr2: [ 1, 2, 3, '<%= blah.one %>' ], attr3: false } + } + + result_obj = { + value_1: 'three' + value_2: 1, + value_3: false, + array: [ 'two', 2, true, { item: { a: 'a', b: 'b', c: false } } ], + object: { attr1: 'attr', attr2: [ 1, 2, 3, [ 1, 2, 3 ] ], attr3: false } + } + + config( 'blah.one', [ 1, 2, 3 ] ) + config( 'blah.two', { a: 'a', b: 'b', c: false } ) + config( 'blah.three', 'three' ) + + config.process( test_obj ).should.eql( result_obj ) + + # PML: merge, set, get and getRaw are implicitly tested in the earlier tests, and + # have no complex logic. In the interests of maintainability, not explicitly tested + # here. + + describe 'config: ', -> + it 'get values from config that has been loaded', ( done ) -> + bootstrap._readConfig 'test/fixtures/config/user.json', 'test/fixtures/config/package.json' + .then -> + try + config.user( 'blah.one' ).should.eql true + config.user( 'blah.two' ).should.eql false + config.user( 'blah.three' ).should.eql 'three' + config.user( 'blah.an_array' ).should.eql [ 'item_1', 'item_3' ] + config.user( 'blah.an_object' ).should.eql { "value_1": 1, "value_2": 'two' } + config.user( 'blah' ).should.eql { "one": true, "two": false, "three": "three", "an_array": [ 'item_1', 'item_3' ], "an_object": { "value_1": 1, "value_2": 'two' } } + + # without merge - all + config.user( 'blah.an_array', 'item_2' ).should.eql 'item_2' + config.user( 'blah.an_array' ).should.eql 'item_2' + + # with merge + config.user( 'blah.an_object', { "value_4": 4 }, true ).should.eql { "value_1": 1, "value_2": 'two', "value_4": 4 } + config.user( 'blah.an_object' ).should.eql { "value_1": 1, "value_2": 'two', "value_4": 4 } + + done() + catch err + done( err ) + + it 'add a value to the loaded config, it\'s still there when asked for again', -> + config.user( 'blah.four', 'four' ).should.eql 'four' + config.user( 'blah.four' ).should.eql 'four' + + it 'add an an array value then override it, it\'s still there when asked for again', -> + config.user( 'blah.a_new_array', [ 'item_1', 'item_3' ], false ).should.eql [ 'item_1', 'item_3' ] + config.user( 'blah.a_new_array', [ 'item_2' ], false ).should.eql [ 'item_2' ] + config.user( 'blah.a_new_array' ).should.eql [ 'item_2' ] + + it 'use merge to add a value to an array, values have been persisted into the config', -> + config.user( 'blah.a_new_array', [ 'item_1', 'item_3' ], false ).should.eql [ 'item_1', 'item_3' ] + config.user( 'blah.a_new_array', [ 'item_2' ], true ).should.eql [ 'item_1', 'item_3', 'item_2' ] + config.user( 'blah.a_new_array' ).should.eql [ 'item_1', 'item_3', 'item_2' ] + + it 'add an object then override it, it\'s still there when asked for again', -> + config.user( 'blah.a_new_object', { value_1: 1, value_2: 'two', }, false ).should.eql { value_1: 1, value_2: 'two', } + config.user( 'blah.a_new_object', { 'value_3': 'three' }, false ).should.eql { value_3: 'three' } + config.user( 'blah.a_new_object' ).should.eql { value_3: 'three' } + + it 'use merge to add a value to an object in the loaded config, values have been persisted into the config', -> + config.user( 'blah.a_new_object', { value_1: 1, value_2: 'two', }, false ).should.eql { value_1: 1, value_2: 'two', } + config.user( 'blah.a_new_object', { 'value_3': 'three' }, true ).should.eql { value_1: 1, value_2: 'two', value_3: 'three' } + config.user( 'blah.a_new_object' ).should.eql { value_1: 1, value_2: 'two', value_3: 'three' } + + # PML: merge, set, get and getRaw are implicitly tested in the earlier tests, and + # have no complex logic. In the interests of maintainability, not explicitly tested + # here. + + + describe 'config.write: ', -> + # TODO(pml): didn't feel like dealing with file handling today + + diff --git a/test/fixtures/bootstrap/empty.json b/test/fixtures/bootstrap/empty.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/test/fixtures/bootstrap/empty.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/test/fixtures/bootstrap/package.json b/test/fixtures/bootstrap/package.json new file mode 100644 index 0000000..96a9659 --- /dev/null +++ b/test/fixtures/bootstrap/package.json @@ -0,0 +1,15 @@ +{ + "standard_values": { + "one": true, + "two": false, + "three": "three" + }, + "structures": { + "a_hash": { + "value_1": "value", + "value_2": true, + "value_3": 3 + }, + "an_array": ["test", 1, true, "john"] + } +} \ No newline at end of file diff --git a/test/fixtures/bootstrap/user.json b/test/fixtures/bootstrap/user.json new file mode 100644 index 0000000..fe7b311 --- /dev/null +++ b/test/fixtures/bootstrap/user.json @@ -0,0 +1,7 @@ +{ + "standard_values": { + "one": false, + "extra_1": 1, + "extra_2": "fred" + } +} \ No newline at end of file diff --git a/test/fixtures/config/package.json b/test/fixtures/config/package.json new file mode 100644 index 0000000..96a9659 --- /dev/null +++ b/test/fixtures/config/package.json @@ -0,0 +1,15 @@ +{ + "standard_values": { + "one": true, + "two": false, + "three": "three" + }, + "structures": { + "a_hash": { + "value_1": "value", + "value_2": true, + "value_3": 3 + }, + "an_array": ["test", 1, true, "john"] + } +} \ No newline at end of file diff --git a/test/fixtures/config/user.json b/test/fixtures/config/user.json new file mode 100644 index 0000000..70c4b29 --- /dev/null +++ b/test/fixtures/config/user.json @@ -0,0 +1,12 @@ +{ + "blah": { + "one": true, + "two": false, + "three": "three", + "an_array": [ "item_1", "item_3"], + "an_object": { + "value_1": 1, + "value_2": "two" + } + } +} \ No newline at end of file diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..8bd4127 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1 @@ +--compilers coffee:coffee-script/register