diff --git a/.babelrc b/.babelrc index aa821c9..1b5d503 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,8 @@ { - "plugins": ["transform-decorators-legacy"] -} + "plugins": [ + "transform-decorators-legacy" + ], + presets: [ + ["es2015", {"loose": true}] + ] +} \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 4fd6382..78c0eb9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -167,11 +167,10 @@ "before": false, "after": true }], - "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords + "keyword-spacing": 2, // http://eslint.org/docs/rules/space-after-keywords "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops - "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case "spaced-comment": 2, // http://eslint.org/docs/rules/spaced-comment (previously known as spaced-line-comment) } -} +} \ No newline at end of file diff --git a/.npm/package/npm-shrinkwrap.json b/.npm/package/npm-shrinkwrap.json index 21e3e9c..58c8671 100644 --- a/.npm/package/npm-shrinkwrap.json +++ b/.npm/package/npm-shrinkwrap.json @@ -2,32 +2,6 @@ "dependencies": { "getenv": { "version": "0.5.0" - }, - "winston": { - "version": "2.1.0", - "dependencies": { - "async": { - "version": "1.0.0" - }, - "colors": { - "version": "1.0.3" - }, - "cycle": { - "version": "1.0.3" - }, - "eyes": { - "version": "0.1.8" - }, - "isstream": { - "version": "0.1.2" - }, - "pkginfo": { - "version": "0.3.1" - }, - "stack-trace": { - "version": "0.0.9" - } - } } } } diff --git a/circle.yml b/circle.yml index bb261ea..898cacc 100644 --- a/circle.yml +++ b/circle.yml @@ -1,11 +1,11 @@ machine: node: - version: 0.10.33 - pre: - - curl https://install.meteor.com | /bin/sh + version: 6.0.0 + dependencies: - pre: - - npm install -g spacejam + override: + - npm install + test: override: - - spacejam test-packages ./ + - npm test diff --git a/package.js b/package.js index 29243f4..44e3b00 100644 --- a/package.js +++ b/package.js @@ -7,102 +7,87 @@ Package.describe({ }); Npm.depends({ - "getenv": "0.5.0", - "winston": "2.1.0", "babel-plugin-transform-decorators-legacy": "1.3.4" }); Package.onUse(function(api) { - api.versionsFrom('1.2.0.1'); + api.versionsFrom('1.4.2'); api.use([ 'coffeescript', - 'check', - 'underscore', 'ecmascript' ]); - api.use([ - 'ejson', - 'ddp', - 'random', - 'mongo', - 'tracker', - 'templating', - 'session', - 'blaze', - 'email', - 'accounts-base', - 'reactive-var' - ], {weak: true}); - api.addFiles([ - 'source/lib/underscore_deep_extend_mixin.js', - 'source/namespace.coffee', - 'source/helpers.coffee', - 'source/configuration.js', - 'source/object.coffee', + 'source/lib/underscore-deep-extend-mixin.js', + 'source/space.js', + 'source/object.js', 'source/logger.js', - 'source/struct.coffee', + 'source/struct.js', 'source/error.js', - 'source/injector.coffee', - 'source/injector_annotations.coffee', - 'source/module.coffee', - 'source/application.coffee' + 'source/injector.js', + 'source/injector-annotations.js', + 'source/module.js', + 'source/application.js', + 'source/loggers/adapter.js', + 'source/loggers/console-adapter.js', + 'source/index.js', + 'source/meteor.js' ]); // Test helpers api.addFiles([ - 'source/testing/bdd-api.coffee' + 'source/testing/bdd-api.js' ]); + api.export([ + 'SpaceObject', + 'Struct', + 'SpaceError', + 'Injector', + 'InjectionError', + 'Logger', + 'LoggingAdapter', + 'ConsoleLogger', + 'Module', + 'Application', + 'Space' + ]); }); Package.onTest(function(api) { api.use([ - 'meteor', 'coffeescript', - 'check', 'ecmascript', 'space:base', // weak-dependencies - 'ddp', - 'random', - 'underscore', - 'mongo', - 'tracker', - 'templating', - 'ejson', - 'accounts-base', - 'email', - 'session', - 'reactive-var', - 'practicalmeteor:munit@2.1.5', + 'practicalmeteor:mocha@2.4.5_6', + 'practicalmeteor:chai@2.1.0_1', + 'practicalmeteor:sinon@1.14.1_2', + 'space:testing@3.0.1' ]); api.addFiles([ // unit tests - 'tests/unit/object.unit.coffee', - 'tests/unit/module.unit.coffee', - 'tests/unit/struct.unit.coffee', - 'tests/unit/application.unit.coffee', - 'tests/unit/injector.unit.coffee', + 'tests/unit/object.unit.js', + 'tests/unit/module.unit.js', + 'tests/unit/struct.unit.js', + 'tests/unit/application.unit.js', + 'tests/unit/injector.unit.js', 'tests/unit/injector_annotations.unit.js', - 'tests/unit/helpers.unit.coffee', + 'tests/unit/helpers.unit.js', 'tests/unit/error.tests.js', 'tests/unit/logger.tests.js', // integration tests 'tests/integration/application_with_modules.spec.js', - 'tests/integration/standalone_application.integration.coffee', 'tests/integration/lifecycle_hooks.tests.js', 'tests/integration/requiring-modules.tests.js', 'tests/integration/module.regressions.js' ]); - }); diff --git a/package.json b/package.json new file mode 100644 index 0000000..13fb0ce --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "space-base", + "version": "4.1.4", + "description": "Modular Application Architecture", + "keywords": [ + "space", + "modular", + "architecture", + "dependencies", + "dependency", + "injection", + "di", + "ioc", + "inversion of control" + ], + "author": "Dominik Guzei, Rhys Bartels-Waller, Darko Mijic, Adam Desivi", + "contributors": [ + "Dominik Guzei (https://github.com/DominikGuzei)", + "Rhys Bartels-Waller (https://github.com/rhyslbw)", + "Darko Mijić (https://github.com/darko-mijic)", + "Jonas Aschenbrenner (https://github.com/Sanjo)", + "Adam Desivi (https://github.com/qejk)" + ], + "license": "MIT", + "engines": { + "node": ">= 4.0.x", + "npm": ">= 1.4.x" + }, + "repository": { + "type": "git", + "url": "git://github.com/meteor-space/base.git" + }, + "dependencies": { + "underscore": "^1.8.0", + "simplecheck": "git://github.com/qejk/simplecheck.git", + "lodash": "^4.17.0" + }, + "devDependencies": { + "babel-core": "^6.21.0", + "babel-cli": "^6.18.0", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-preset-es2015": "^6.14.0", + "chai": "^3.5.0", + "sinon": "^1.17.7", + "sinon-chai": "^2.8.0", + "mocha": "^3.2.0", + "space-testing": "git://github.com/meteor-space/testing.git#feature\/convert-to-npm" + + }, + "scripts": { + "build": "npm run compile", + "compile": "./node_modules/.bin/babel -d lib/ src/", + "prepublish": "npm run compile", + "test": "npm run compile && node_modules/mocha/bin/mocha --require babel-core/register --recursive test/**/*.js", + "test-watch": "npm test -- --watch" + } +} \ No newline at end of file diff --git a/source/application.coffee b/source/application.coffee deleted file mode 100644 index dbef815..0000000 --- a/source/application.coffee +++ /dev/null @@ -1,20 +0,0 @@ - -class Space.Application extends Space.Module - - configuration: { - appId: null - } - - @define: (appName, prototype) -> - prototype.toString = -> appName # For better debugging - return @extend appName, prototype - - constructor: (options={}) -> - super - @modules = {} - @configuration = options.configuration || {} - @constructor.publishedAs = @constructor.name - @initialize this, options.injector ? new Space.Injector() - - # Make it possible to override configuration (at any nested level) - configure: (options) -> _.deepExtend @configuration, options diff --git a/source/configuration.js b/source/configuration.js deleted file mode 100644 index 3c7095b..0000000 --- a/source/configuration.js +++ /dev/null @@ -1,40 +0,0 @@ - -if (Meteor.isServer) { - - let getenv = Npm.require('getenv'); - // Wrapper - Space.getenv = getenv; - - Space.configuration = Space.getenv.multi({ - log: { - enabled: ['SPACE_LOG_ENABLED', false, 'bool'], - minLevel: ['SPACE_LOG_MIN_LEVEL', 'info', 'string'] - } - }); - - // Pass down to the client - _.deepExtend(Meteor.settings, { - public: { - log: { - enabled: Space.configuration.log.enabled, - minLevel: Space.configuration.log.minLevel - } - } - }); - - __meteor_runtime_config__.PUBLIC_SETTINGS = Meteor.settings.public; - -} - -if (Meteor.isClient) { - - let log = Meteor.settings.public.log; - - // Guard and defaults when not loaded on server - Space.configuration = { - log: { - enabled: log && log.enabled || false, - minLevel: log && log.minLevel || 'info' - } - }; -} diff --git a/source/error.js b/source/error.js deleted file mode 100644 index 782089a..0000000 --- a/source/error.js +++ /dev/null @@ -1,49 +0,0 @@ -let IntermediateInheritor = function() {}; -IntermediateInheritor.prototype = Error.prototype; - -Space.Error = function(params) { - this._invokeConstructionCallbacks.apply(this, arguments); - let data = null; - if (_.isString(params)) { - data = { message: params }; - } else if (_.isObject(params)) { - data = params; - } else { - data = {}; - } - Space.Struct.call(this, this.extractErrorProperties(data)); - return this; -}; - -Space.Error.prototype = new IntermediateInheritor(); - -_.extend( - Space.Error.prototype, // target - Space.Struct.prototype, - _.omit(Space.Object.prototype, 'toString'), - { - message: '', - fields() { - let fields = Space.Struct.prototype.fields.call(this); - _.extend(fields, { - name: String, - message: String, - stack: Match.Optional(String), - code: Match.Optional(Match.Integer) - }); - return fields; - }, - extractErrorProperties(data) { - let message = data.message ? data.message : this.message; - let error = Error.call(this, message); - data.name = error.name = this.constructor.name; - data.message = error.message; - if (error.stack !== undefined) data.stack = error.stack; - return data; - } - } -); - -_.extend(Space.Error, _.omit(Space.Object, 'toString'), { - __keepToStringMethod__: true // Do not override #toString method -}); diff --git a/source/helpers.coffee b/source/helpers.coffee deleted file mode 100644 index 8be221b..0000000 --- a/source/helpers.coffee +++ /dev/null @@ -1,25 +0,0 @@ - -global = this - -# Resolves a (possibly nested) path to a global object -# Returns the object or null (if not found) -Space.resolvePath = (path) -> - if !path? then throw new Error "Cannot resolve invalid path <#{path}>" - if path == '' then return global - - # If there is a direct reference just return it - if Space.registry[path]? then return Space.registry[path] - if Space.Module?.published[path]? then return Space.Module.published[path] - parts = path.split '.' - result = global # Start with global namespace - for key in parts # Move down the object chain - result = result?[key] ? null - # Take published space modules into account - # to solve the Meteor package scoping problem - if !result? then throw new Error "Could not resolve path '#{path}'" - return result - -Space.namespace = (id) -> Space.registry[id] = new Space.Namespace(id) - -Space.capitalizeString = (string) -> - string.charAt(0).toUpperCase() + string.slice(1) diff --git a/source/injector.coffee b/source/injector.coffee deleted file mode 100644 index 0086bad..0000000 --- a/source/injector.coffee +++ /dev/null @@ -1,209 +0,0 @@ - -Space.Error.extend(Space, 'InjectionError') - -class Space.Injector - - ERRORS: { - cannotMapUndefinedId: -> 'Cannot map or .' - mappingExists: (id) -> "<#{id}> would be overwritten. Use for that." - noMappingFound: (id) -> "no mapping found for <#{id}>" - cannotGetValueForUndefined: -> "Cannot get injection mapping for ." - } - - constructor: (providers) -> - @_mappings = {} - @_providers = providers ? Injector.DEFAULT_PROVIDERS - - toString: -> 'Instance ' - - map: (id, override) -> - if not id? - throw new Space.InjectionError(@ERRORS.cannotMapUndefinedId()) - mapping = @_mappings[id] - # Avoid accidential override of existing mapping - if mapping? and !override - throw new Space.InjectionError(@ERRORS.mappingExists(id)) - else if mapping? and override - mapping.markForOverride() - return mapping - else - @_mappings[id] = new Mapping id, @_providers - return @_mappings[id] - - override: (id) -> @map id, true - - remove: (id) -> delete @_mappings[id] - - get: (id, dependentObject=null) -> - if !id? - throw new Space.InjectionError(@ERRORS.cannotGetValueForUndefined()) - if not @_mappings[id]? - throw new Space.InjectionError(@ERRORS.noMappingFound(id)) - dependency = @_mappings[id].provide(dependentObject) - @injectInto dependency - return dependency - - create: (id) -> @get id - - injectInto: (value) -> - unless _.isObject(value) and !value.__dependenciesInjected__ then return - if Object.defineProperty? - # Flag this object as injected - Object.defineProperty value, '__dependenciesInjected__', - enumerable: false - configurable: false - writable: false - value: true - else - # support old engines without Object.defineProperty - value.__dependenciesInjected__ = true - # Get flat map of dependencies (possibly inherited) - dependencies = @_mapDependencies value - # Inject into dependencies to create the object graph - for key, id of dependencies - try - value[key] ?= @get(id, value) - catch error - error.message += " for {#{key}: '#{id}'} in <#{value}>. Did you forget - to map it in your application?" - throw error - # Notify when dependencies are ready - if value.onDependenciesReady? then value.onDependenciesReady() - - addProvider: (name, provider) -> @_providers[name] = provider - - getMappingFor: (id) -> @_mappings[id] - - getIdForValue: (value) -> - for id, mapping of @_mappings - return id if mapping.getProvider().getValue() is value - - release: (dependent) -> - for id, mapping of @_mappings - mapping.release(dependent) if mapping.hasDependent(dependent) - - _mapDependencies: (value, deps={}) -> - Class = value.constructor ? null - SuperClass = Class.__super__ ? null - # Recurse down the prototype chain - if SuperClass? then @_mapDependencies SuperClass.constructor::, deps - # Add dependencies of current value - deps[key] = id for key, id of value.dependencies - return deps - - _resolveValue: (path) -> Space.resolvePath path - -# ========= PRIVATE CLASSES ========== # - -class Mapping - - _id: null - _provider: null - _dependents: null - _overrideInDependents: false - - constructor: (@_id, providers) -> - @_dependents = [] - @[key] = @_setup(provider) for key, provider of providers - - toString: -> 'Instance ' - - provide: (dependent) -> - # Register depented objects for this mapping so that their - # dependencies can overwritten later on. - @_dependents.push(dependent)if dependent? and not @hasDependent(dependent) - @_provider.provide() - - markForOverride: -> @_overrideInDependents = true - - hasDependent: (dependent) -> @getIndexOfDependee(dependent) > -1 - - getIndexOfDependee: (dependent) -> @_dependents.indexOf(dependent) - - release: (dependent) -> @_dependents.splice(@getIndexOfDependee(dependent), 1) - - getId: -> @_id - - getProvider: -> @_provider - - _setup: (provider) -> - return (value) => # We are inside an API call like injector.map('this').to('that') - # Set the provider of this mapping to what the API user chose - try - @_provider = new provider @_id, value - catch error - error.message += " could not be found! Maybe you forgot to - include a file in package.js?" - throw error - # Override the dependency in all dependent objects if this mapping is flagged - if @_overrideInDependents - # Get the value from the provider - value = @_provider.provide() - # Loop over the dependents - for dependent in @_dependents - # Loop over their dependencies and override the one this mapping - # is managing if it exists (it should) - dependencies = dependent.dependencies ? {} - for key, id of dependencies - if id is @_id - dependent[key] = value - dependent.onDependencyChanged?(key, value) - - # Reset the flag to override dependencies - @_overrideInDependents = false - -# ========= DEFAULT PROVIDERS ======== # - -class Provider - - _id: null - _value: null - - constructor: (@_id, @_value) -> - - getValue: -> @_value - -class ValueProvider extends Provider - - constructor: -> - super - if not @_value? - if (typeof @_id is 'string') - @_value = Space.resolvePath(@_id) - else - @_value = @_id - - toString: -> 'Instance ' - - provide: -> @_value - -class InstanceProvider extends Provider - - toString: -> 'Instance ' - - provide: -> new @_value() - -class SingletonProvider extends Provider - - _singleton: null - - constructor: -> - super - if not @_value? then @_value = @_id - if typeof(@_value) is 'string' then @_value = Space.resolvePath(@_value) - - toString: -> 'Instance ' - - provide: -> - if not @_singleton? then @_singleton = new @_value() - return @_singleton - -Space.Injector.DEFAULT_PROVIDERS = - - to: ValueProvider - toStaticValue: ValueProvider - asStaticValue: ValueProvider - toClass: InstanceProvider - toInstancesOf: InstanceProvider - asSingleton: SingletonProvider - toSingleton: SingletonProvider diff --git a/source/injector_annotations.coffee b/source/injector_annotations.coffee deleted file mode 100644 index 8ab6eb1..0000000 --- a/source/injector_annotations.coffee +++ /dev/null @@ -1,17 +0,0 @@ -@Space.Dependency = (propertyName, dependencyId) -> - if (typeof dependencyId == 'undefined') - dependencyId = propertyName - (target) -> - if target.prototype.dependencies and not target.prototype.hasOwnProperty('Dependencies') - target.prototype.dependencies = _.clone target.prototype.dependencies - target.prototype.dependencies ?= {} - target.prototype.dependencies[propertyName] = dependencyId - return target - -@Space.RequireModule = (moduleId) -> - (target) -> - if target.prototype.requiredModules and not target.prototype.hasOwnProperty('RequiredModules') - target.prototype.requiredModules = _.clone target.prototype.requiredModules - target.prototype.requiredModules ?= [] - target.prototype.requiredModules.push moduleId - return target diff --git a/source/logger.js b/source/logger.js deleted file mode 100644 index bf6488c..0000000 --- a/source/logger.js +++ /dev/null @@ -1,104 +0,0 @@ -let config = Space.configuration; - -if (Meteor.isServer) { - winston = Npm.require('winston'); -} - -Space.Object.extend(Space, 'Logger', { - - _logger: null, - _minLevel: 6, - _state: 'stopped', - - _levels: { - 'error': 3, - 'warning': 4, - 'warn': 4, - 'info': 6, - 'debug': 7 - }, - - Constructor() { - if (Meteor.isServer) { - this._logger = new winston.Logger({ - transports: [ - new winston.transports.Console({ - colorize: true, - prettyPrint: true - }) - ] - }); - this._logger.setLevels(winston.config.syslog.levels); - } - if (Meteor.isClient) { - this._logger = console; - } - }, - - setMinLevel(name) { - let newCode = this._levelCode(name); - if (this._minLevel !== newCode) { - this._minLevel = newCode; - if (Meteor.isServer) { - this._logger.transports.console.level = name; - } - } - }, - - start() { - if (this._is('stopped')) { - this._state = 'running'; - } - }, - - stop() { - if (this._is('running')) { - this._state = 'stopped'; - } - }, - - debug(message) { - check(message, String); - this._log('debug', arguments); - }, - - info(message) { - check(message, String); - this._log('info', arguments); - }, - - warning(message) { - check(message, String); - if (Meteor.isClient) - this._log('warn', arguments); - if (Meteor.isServer) - this._log('warning', arguments); - }, - - error(message) { - check(message, String); - this._log('error', arguments); - }, - - _levelCode(name) { - return this._levels[name]; - }, - - _is(expectedState) { - if (this._state === expectedState) return true; - }, - - _log(level, message) { - if(this._is('running') && this._levelCode(level) <= this._minLevel) { - this._logger[level].apply(this._logger, message); - } - } - -}); - -Space.log = new Space.Logger(); - -if (config.log.enabled) { - Space.log.setMinLevel(config.log.minLevel); - Space.log.start(); -} diff --git a/source/module.coffee b/source/module.coffee deleted file mode 100644 index 32719e6..0000000 --- a/source/module.coffee +++ /dev/null @@ -1,210 +0,0 @@ - -class Space.Module extends Space.Object - - ERRORS: { - injectorMissing: 'Instance of Space.Injector needed to initialize module.' - } - - configuration: {} - requiredModules: null - # An array of paths to classes that you want to become - # singletons in your application e.g: ['Space.messaging.EventBus'] - # these are automatically mapped and created on `app.run()` - singletons: [] - injector: null - _state: 'constructed' - - constructor: -> - super - @requiredModules ?= [] - - initialize: (@app, @injector, isSubModule=false) -> - return if not @is('constructed') # only initialize once - if not @injector? then throw new Error @ERRORS.injectorMissing - @_state = 'configuring' - Space.log.debug("#{@constructor.publishedAs}: initialize") - # Setup basic mappings required by all modules if this the top-level module - unless isSubModule - @injector.map('Injector').to @injector - @_mapSpaceServices() - @_mapMeteorApis() - - # Setup required modules - for moduleId in @requiredModules - # Create a new module instance if not already registered with the app - unless @app.modules[moduleId]? - moduleClass = Space.Module.require(moduleId, this.constructor.name) - @app.modules[moduleId] = new moduleClass() - # Initialize required module - module = @app.modules[moduleId] - module.initialize(@app, @injector, true) - - # Merge in own configuration to give the chance for overwriting. - if isSubModule - _.deepExtend(@app.configuration, @configuration) - @configuration = @app.configuration - else - # The app can override all other modules - _.deepExtend(@configuration, @constructor.prototype.configuration) - - # Provide lifecycle hook before any initialization has been done - @beforeInitialize?() - # Give every module access Npm - if Meteor.isServer then @npm = Npm - # Top-level module - if not isSubModule - @injector.map('configuration').to(@configuration) - @_runOnInitializeHooks() - @_autoMapSingletons() - @_autoCreateSingletons() - @_runAfterInitializeHooks() - - start: -> - if @is('running') then return - @_runLifeCycleAction 'start' - @_state = 'running' - - reset: -> - return if Meteor.isServer and process.env.NODE_ENV is 'production' - return if @_isResetting - restartRequired = @is('running') - @_isResetting = true - if restartRequired then @stop() - @_runLifeCycleAction 'reset' - if restartRequired then @start() - # There is no other way to avoid reset being called multiple times - # if multiple modules require the same sub-module. - Meteor.defer => @_isResetting = false - - stop: -> - if @is('stopped') then return - @_runLifeCycleAction 'stop', => - @_state = 'stopped' - - is: (expectedState) -> expectedState is @_state - - # ========== STATIC MODULE MANAGEMENT ============ # - - @define: (moduleName, prototype={}) -> - prototype.toString = -> moduleName # For better debugging - @publish Space.Module.extend(moduleName, prototype), moduleName - - # All published modules register themselves here - @published = {} - - # Publishes a module into the space environment to make it - # visible and requireable for other modules and the application - @publish: (module, identifier) -> - module.publishedAs = module.name = identifier - if Space.Module.published[identifier]? - throw new Error "Two modules tried to be published as <#{identifier}>" - else - Space.Module.published[identifier] = module - - # Retrieve a module by identifier - @require: (requiredModule, requestingModule) -> - module = Space.Module.published[requiredModule] - if not module? - throw new Error "Could not find module <#{requiredModule}> - required by <#{requestingModule}>" - else - return module - - # Invokes the lifecycle action on all required modules, then on itself, - # calling the instance hooks before, on, and after - _runLifeCycleAction: (action, func) -> - @_invokeActionOnRequiredModules action - Space.log.debug("#{@constructor.publishedAs}: #{action}") - this["before#{Space.capitalizeString(action)}"]?() - func?() - this["on#{Space.capitalizeString(action)}"]?() - this["after#{Space.capitalizeString(action)}"]?() - - # Provide lifecycle hook after this module was configured and injected - _runOnInitializeHooks: -> - @_invokeActionOnRequiredModules '_runOnInitializeHooks' - # Never run this hook twice - if @is('configuring') - Space.log.debug("#{@constructor.publishedAs}: onInitialize") - @_state = 'initializing' - # Inject required dependencies into this module - @injector.injectInto this - # Call custom lifecycle hook if existant - @onInitialize?() - - _autoMapSingletons: -> - @_invokeActionOnRequiredModules '_autoMapSingletons' - if @is('initializing') - Space.log.debug("#{@constructor.publishedAs}: _autoMapSingletons") - @_state = 'auto-mapping-singletons' - # Map classes that are declared as singletons - @injector.map(singleton).asSingleton() for singleton in @singletons - - _autoCreateSingletons: -> - @_invokeActionOnRequiredModules '_autoCreateSingletons' - if @is('auto-mapping-singletons') - Space.log.debug("#{@constructor.publishedAs}: _autoCreateSingletons") - @_state = 'auto-creating-singletons' - # Create singleton classes - @injector.create(singleton) for singleton in @singletons - - # After all modules in the tree have been configured etc. invoke last hook - _runAfterInitializeHooks: -> - @_invokeActionOnRequiredModules '_runAfterInitializeHooks' - # Never run this hook twice - if @is('auto-creating-singletons') - Space.log.debug("#{@constructor.publishedAs}: afterInitialize") - @_state = 'initialized' - # Call custom lifecycle hook if existant - @afterInitialize?() - - _invokeActionOnRequiredModules: (action) -> - @app.modules[moduleId][action]?() for moduleId in @requiredModules - - _wrapLifecycleHook: (hook, wrapper) -> - this[hook] ?= -> - this[hook] = _.wrap(this[hook], wrapper) - - _mapSpaceServices: -> - @injector.map('log').to Space.log - - _mapMeteorApis: -> - Space.log.debug("#{@constructor.publishedAs}: _mapMeteorApis") - # Map Meteor standard packages - @injector.map('Meteor').to Meteor - if Package.ejson? - @injector.map('EJSON').to Package.ejson.EJSON - if Package.ddp? - @injector.map('DDP').to Package.ddp.DDP - if Package.random? - @injector.map('Random').to Package.random.Random - @injector.map('underscore').to Package.underscore._ - if Package.mongo? - @injector.map('Mongo').to Package.mongo.Mongo - if Meteor.isServer - @injector.map('MongoInternals').to Package.mongo.MongoInternals - - if Meteor.isClient - if Package.tracker? - @injector.map('Tracker').to Package.tracker.Tracker - if Package.templating? - @injector.map('Template').to Package.templating.Template - if Package.session? - @injector.map('Session').to Package.session.Session - if Package.blaze? - @injector.map('Blaze').to Package.blaze.Blaze - - if Meteor.isServer - @injector.map('check').to check - @injector.map('Match').to Match - @injector.map('process').to process - @injector.map('Future').to Npm.require 'fibers/future' - - if Package.email? - @injector.map('Email').to Package.email.Email - - if Package['accounts-base']? - @injector.map('Accounts').to Package['accounts-base'].Accounts - - if Package['reactive-var']? - @injector.map('ReactiveVar').to Package['reactive-var'].ReactiveVar diff --git a/source/namespace.coffee b/source/namespace.coffee deleted file mode 100644 index 078af59..0000000 --- a/source/namespace.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class Namespace - constructor: (@_path) -> - getPath: -> this._path - toString: -> @_path - -# Define global namespace for the space framework -@Space = new Namespace 'Space' -@Space.Namespace = Namespace -@Space.registry = {} \ No newline at end of file diff --git a/source/object.coffee b/source/object.coffee deleted file mode 100644 index d4e5808..0000000 --- a/source/object.coffee +++ /dev/null @@ -1,303 +0,0 @@ - -class Space.Object - - # ============= PUBLIC PROTOTYPE ============== # - - # Assign given properties to the instance - constructor: (properties) -> - @_invokeConstructionCallbacks.apply(this, arguments) - # Copy properties to instance by default - @[key] = value for key, value of properties - - onDependenciesReady: -> - # Let mixins initialize themselves when dependencies are ready - for mixin in @constructor._getAppliedMixins() - mixin.onDependenciesReady?.call(this) - - toString: -> @constructor.toString() - - hasSuperClass: -> @constructor.__super__? - - # Returns either the super class constructor (if no param given) or - # the prototype property or method with [key] - superClass: (key) -> - sup = @constructor.__super__.constructor - if key? then sup.prototype[key] else sup - - # Returns true if the passed in mixin has been applied to this or a super class - hasMixin: (mixin) -> _.contains(@constructor._getAppliedMixins(), mixin) - - # This method needs to stay separate from the constructor so that - # Space.Error can use it too! - _invokeConstructionCallbacks: -> - # Let mixins initialize themselves on construction - for mixin in @constructor._getAppliedMixins() - mixin.onConstruction?.apply(this, arguments) - - # ============= PUBLIC STATIC ============== # - - # Extends this class and return a child class with inherited prototype - # and static properties. - # - # There are various ways you can call this method: - # - # 1. Space.Object.extend() - # -------------------------------------------- - # Creates an anonymous child class without extra prototype properties. - # Basically the same as `class extend Space.Object` in coffeescript - # - # 2. Space.Object.extend(className) - # -------------------------------------------- - # Creates a named child class without extra prototype properties. - # Basically the same as `class ClassName extend Space.Object` in coffeescript - # - # 3. Space.Object.extend(classPath) - # -------------------------------------------- - # Creates a child class with fully qualified class path like "my.custom.Class" - # assigned and registered internally so that Space.resolvePath can find it. - # This also assigns the class path as type, which can be used for serialization - # - # 4. Space.Object.extend({ prop: 'first', … }) - # -------------------------------------------- - # Creates an anonymous child class with extra prototype properties. - # Same as: - # class extend Space.Object - # prop: 'first' - # - # 5. Space.Object.extend(namespace, className) - # -------------------------------------------- - # Creates a named class which inherits from Space.Object and assigns - # it to the given namespace object. - # - # 6. Space.Object.extend(className, prototype) - # -------------------------------------------- - # Creates a named class which inherits from Space.Object and extra prototype - # properties which are assigned to the new class - # - # 7. Space.Object.extend(classPath, prototype) - # -------------------------------------------- - # Creates a registered class which inherits from Space.Object and extra prototype - # properties which are assigned to the new class - # - # 8. Space.Object.extend(namespace, className, prototype) - # -------------------------------------------- - # Creates a named class which inherits from Space.Object, has extra prototype - # properties and is assigned to the given namespace. - @extend: (args...) -> - - # Defaults - namespace = {} - classPath = null - className = '_Class' # Same as coffeescript - extension = {} - - # Only one param: (extension) OR (className) OR (classPath) -> - if args.length is 1 - if _.isObject(args[0]) then extension = args[0] - if _.isString(args[0]) - # (className) OR (classPath) - if args[0].indexOf('.') != -1 - # classPath - classPath = args[0] - className = classPath.substr(classPath.lastIndexOf('.') + 1) - else - # className - className = classPath = args[0] - - # Two params must be: (namespace, className) OR (className, extension) -> - if args.length is 2 - if _.isObject(args[0]) and _.isString(args[1]) - namespace = args[0] - className = args[1] - extension = {} - else if _.isString(args[0]) and _.isObject(args[1]) - # (className) OR (classPath) - namespace = {} - extension = args[1] - if args[0].indexOf('.') != -1 - # classPath - classPath = args[0] - className = classPath.substr(classPath.lastIndexOf('.') + 1) - else - # className - className = classPath = args[0] - - # All three params: (namespace, className, extension) -> - if args.length is 3 - namespace = args[0] - className = args[1] - extension = args[2] - - check namespace, Match.OneOf(Match.ObjectIncluding({}), Space.Namespace, Function) - check classPath, Match.OneOf(String, null) - check className, String - check extension, Match.ObjectIncluding({}) - - # Assign the optional custom constructor for this class - Parent = this - Constructor = extension.Constructor ? -> Parent.apply(this, arguments) - - # Create a named constructor for this class so that debugging - # consoles are displaying the class name nicely. - Child = new Function('initialize', 'return function ' + className + '() { - initialize.apply(this, arguments); - }')(Constructor) - - # Add subclass to parent class - Parent._subClasses.push(Child) - - # Copy the static properties of this class over to the extended - Child[key] = this[key] for key of this - Child._subClasses = [] - - # Copy over static class properties defined on the extension - if extension.statics? - _.extend Child, extension.statics - delete extension.statics - - # Extract mixins before they get added to prototype - mixins = extension.mixin - delete extension.mixin - - # Extract onExtending callback and avoid adding it to prototype - onExtendingCallback = extension.onExtending - delete extension.onExtending - - # Javascript prototypal inheritance "magic" - Ctor = -> - @constructor = Child - return - Ctor.prototype = Parent.prototype - Child.prototype = new Ctor() - Child.__super__ = Parent.prototype - - # Apply mixins - if mixins? then Child.mixin(mixins) - - # Merge the extension into the class prototype - @_mergeIntoPrototype Child.prototype, extension - - # Add the class to the namespace - if namespace? - namespace[className] = Child - if namespace instanceof Space.Namespace - classPath = "#{namespace.getPath()}.#{className}" - - # Add type information to the class - Child.type classPath if classPath? - - # Invoke the onExtending callback after everything has been setup - onExtendingCallback?.call(Child) - - return Child - - @toString: -> @classPath - - @type: (@classPath) -> - # Register this class with its class path - Space.registry[@classPath] = this - try - # Add the class to the resolved namespace - path = @classPath.substr 0, @classPath.lastIndexOf('.') - namespace = Space.resolvePath path - className = @classPath.substr(@classPath.lastIndexOf('.') + 1) - namespace[className] = this - - # Create and instance of the class that this method is called on - # e.g.: Space.Object.create() would return an instance of Space.Object - @create: -> - # Use a wrapper class to hand the constructor arguments - # to the context class that #create was called on - args = arguments - Context = this - wrapper = -> Context.apply this, args - wrapper extends Context - new wrapper() - - # Mixin properties and methods to the class prototype and merge - # properties that are plain objects to support the mixin of configs etc. - @mixin: (mixins) -> - if _.isArray(mixins) - @_applyMixin(mixin) for mixin in mixins - else - @_applyMixin(mixins) - - # Returns true if this class has a super class - @hasSuperClass: -> @__super__? - - @isSubclassOf: (sup) -> - isSubclass = this.prototype instanceof sup - isSameClass = this is sup - return isSubclass || isSameClass - - # Returns either the super class constructor (if no param given) or - # the static property or method with [key] - @superClass: (key) -> - return undefined if !@__super__? - sup = @__super__.constructor - if key? then sup[key] else sup - - # Returns a flat, uniq array of all sub classes - @subClasses: -> - subs = [].concat(@_subClasses) - subs = subs.concat(subClass.subClasses()) for subClass in subs - return _.uniq(subs) - - # Returns true if the passed in mixin has been applied to this or a super class - @hasMixin: (mixin) -> _.contains(@_getAppliedMixins(), mixin) - - # ============= PRIVATE STATIC ============== # - - @_subClasses: [] - - @_applyMixin: (mixin) -> - # Add the original mixin to the registry so we can ask if a specific - # mixin has been added to a host class / instance - # Each class has its own mixins array - hasMixins = @_appliedMixins? - areInherited = hasMixins and @superClass('_appliedMixins') is @_appliedMixins - if !hasMixins or areInherited then @_appliedMixins = [] - - # Keep the mixins array clean from duplicates - @_appliedMixins.push(mixin) if !_.contains(@_appliedMixins, mixin) - - # Create a clone so that we can remove properties without affecting the global mixin - mixinCopy = _.clone mixin - - # Remove hooks from mixin, so that they are not added to host class - delete mixinCopy.onDependenciesReady - delete mixinCopy.onConstruction - - # Mixin static properties into the host class - if mixinCopy.statics? - statics = mixinCopy.statics - _.extend(this, statics) - _.extend(sub, statics) for sub in @subClasses() - delete mixinCopy.statics - - # Give mixins the chance to do static setup when applied to the host class - mixinCopy.onMixinApplied?.call this - delete mixinCopy.onMixinApplied - - # Copy over the mixin to the prototype and merge objects - @_mergeIntoPrototype @prototype, mixinCopy - - @_getAppliedMixins: -> - mixins = [] - mixins = mixins.concat(@superClass()._getAppliedMixins()) if @hasSuperClass() - mixins = mixins.concat(@_appliedMixins) if @_appliedMixins? - return _.uniq(mixins) - - @_mergeIntoPrototype: (prototype, extension) -> - # Helper function to check for object literals only - isPlainObject = (value) -> - _.isObject(value) and !_.isArray(value) and !_.isFunction(value) - for key, value of extension - hasProperty = prototype.hasOwnProperty(key) - if hasProperty and isPlainObject(value) and isPlainObject(prototype[key]) - # Deep extend plain objects - _.deepExtend(prototype[key], _.clone(value)) - else - value = _.clone(value) if isPlainObject(value) - # Set non-existing props and override existing methods - prototype[key] = value \ No newline at end of file diff --git a/source/struct.coffee b/source/struct.coffee deleted file mode 100644 index 899d546..0000000 --- a/source/struct.coffee +++ /dev/null @@ -1,18 +0,0 @@ - -class Space.Struct extends Space.Object - - @fields: {} - - constructor: (data={}) -> - @_checkFields(data) - super - - fields: -> _.clone(@constructor.fields) ? {} - - toPlainObject: -> - copy = {} - copy[key] = @[key] for key of @fields() when @[key] != undefined - return copy - - # Use the fields configuration to check given data during runtime - _checkFields: (data) -> check data, @fields() \ No newline at end of file diff --git a/source/testing/bdd-api.coffee b/source/testing/bdd-api.coffee deleted file mode 100644 index 4bb1772..0000000 --- a/source/testing/bdd-api.coffee +++ /dev/null @@ -1,26 +0,0 @@ -registeredBddApis = [] - -Space.Module.registerBddApi = (api) -> registeredBddApis.push api - -Space.Module.test = Space.Application.test = (systemUnderTest, app=null) -> - throw new Error 'Cannot test ' unless systemUnderTest? - testApi = null - isModule = isSubclassOf(this, Space.Module) - isApplication = isSubclassOf(this, Space.Application) - - # BDD API relies on dependency injection provided by Application - if !app? - if isApplication - app = new this() - else - app = new (Space.Application.define('TestApp', { - configuration: { appId: 'testApp' }, - requiredModules: [this.publishedAs] - })) - - for api in registeredBddApis - returnValue = api(app, systemUnderTest) - testApi = returnValue if returnValue? - - if not testApi? then throw new Error "No testing API found for #{systemUnderTest}" - return testApi \ No newline at end of file diff --git a/src/application.js b/src/application.js new file mode 100644 index 0000000..0c32728 --- /dev/null +++ b/src/application.js @@ -0,0 +1,32 @@ +import _ from 'underscore'; +import Module from './module.js'; +import {Injector} from './injector.js'; + +const Application = Module.extend('Space.Application', { + + statics: { + define(classPath, prototype) { + prototype.toString = () => appName; // For better debugging + return this.extend(classPath, prototype); + } + }, + + configuration: { + appId: null + }, + + Constructor(options = {}) { + Module.call(this, options); + this.modules = {}; + this.configuration = options.configuration || {}; + this.constructor.publishedAs = this.constructor.name; + this.initialize(this, options.injector || new Injector()); + }, + + // Make it possible to override configuration (at any nested level) + configure(options) { + _.deepExtend(this.configuration, options); + } +}); + +export default Application; diff --git a/src/error.js b/src/error.js new file mode 100644 index 0000000..476d8c3 --- /dev/null +++ b/src/error.js @@ -0,0 +1,66 @@ +import _ from 'underscore'; +import {entries as ObjectEntries} from 'lodash'; +import {optional, Integer} from 'simplecheck'; +import Struct from './struct.js'; +import SpaceObject from './object.js'; + +const IntermediateInheritor = function() {}; +IntermediateInheritor.prototype = Error.prototype; + +const SpaceError = function(params) { + let data = null; + if (_.isString(params)) { + data = { message: params }; + } else if (_.isObject(params)) { + data = params; + } else { + data = {}; + } + const properties = this.extractErrorProperties(data); + + this._checkFields(properties); + this._invokeConstructionCallbacks.apply(this, arguments); + // Copy properties to instance by default + for (let [key, value] of ObjectEntries(properties)) { + this[key] = value; + } + return this; +}; + +SpaceError.prototype = new IntermediateInheritor(); + +_.extend( + SpaceError.prototype, // target + Struct.prototype, + _.omit(SpaceObject.prototype, 'toString'), + { + message: '', + fields() { + let fields = _.clone(this.constructor.fields) || {}; + _.extend(fields, { + name: String, + message: String, + stack: optional(String), + code: optional(Integer) + }); + return fields; + }, + extractErrorProperties(data) { + let message = data.message ? data.message : this.message; + let error = Error.call(this, message); + data.name = error.name = this.constructor.name; + data.message = error.message; + if (error.stack !== undefined) data.stack = error.stack; + return data; + } + } +); + +_.extend(SpaceError, _.omit(SpaceObject, 'toString'), { + __keepToStringMethod__: true // Do not override #toString method +}); +SpaceError.prototype.toString = function() { + return this.message; +}; + +export default SpaceError; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6a2aa8e --- /dev/null +++ b/src/index.js @@ -0,0 +1,35 @@ +import Space from './space.js'; +import SpaceObject from './object.js'; +import Struct from './struct.js'; +import SpaceError from './error.js'; +import {Injector, InjectionError} from './injector.js'; +import Logger from './logger.js'; +import LoggingAdapter from './loggers/adapter.js'; +import ConsoleLogger from './loggers/console-adapter.js'; +import Module from './module.js'; +import Application from './application.js'; + +Space.Object = SpaceObject; +Space.Struct = Struct; +Space.Error = SpaceError; +Space.Injector = Injector; +Space.InjectionError = InjectionError; +Space.Logger = Logger; +Space.LoggingAdapter = LoggingAdapter; +Space.ConsoleLogger = ConsoleLogger; +Space.Module = Module; +Space.Application = Application; + +export { + SpaceObject as SpaceObject, + Struct as Struct, + SpaceError as SpaceError, + Injector as Injector, + InjectionError as InjectionError, + Logger as Logger, + LoggingAdapter as LoggingAdapter, + ConsoleLogger as ConsoleLogger, + Module as Module, + Application as Application, + Space as Space +}; diff --git a/src/injector.js b/src/injector.js new file mode 100644 index 0000000..d7adcc6 --- /dev/null +++ b/src/injector.js @@ -0,0 +1,329 @@ +import _ from 'underscore'; +import { + isNil, + entries as ObjectEntries, + values as ObjectValues +} from 'lodash'; +import SpaceError from './error.js'; +import Space from './space.js'; + +const InjectionError = SpaceError.extend('Space.InjectionError'); + +class Injector { + constructor(providers) { + this._mappings = {}; + this._providers = providers || Injector.DEFAULT_PROVIDERS; + } + + toString() { + return 'Instance '; + } + + map(id, override) { + if (isNil(id)) { + throw new InjectionError(this.ERRORS.cannotMapUndefinedId()); + } + const mapping = this._mappings[id]; + // Avoid accidential override of existing mapping + if (!isNil(mapping) && !override) { + throw new InjectionError(this.ERRORS.mappingExists(id)); + } else if (!isNil(mapping) && override) { + mapping.markForOverride(); + return mapping; + } else { + this._mappings[id] = new Mapping(id, this._providers); + return this._mappings[id]; + } + } + + override(id) { + return this.map(id, true); + } + + remove(id) { + delete this._mappings[id]; + } + + get(id, dependentObject = null) { + if (isNil(id)) { + throw new InjectionError(this.ERRORS.cannotGetValueForUndefined()); + } + if (isNil(this._mappings[id])) { + throw new InjectionError(this.ERRORS.noMappingFound(id)); + } + const dependency = this._mappings[id].provide(dependentObject); + this.injectInto(dependency); + return dependency; + } + + create(id) { + return this.get(id); + } + + injectInto(value) { + if (!(_.isObject(value) && !value.__dependenciesInjected__)) {return;} + + if (!isNil(Object.defineProperty)) { + // Flag this object as injected + Object.defineProperty(value, '__dependenciesInjected__', { + enumerable: false, + configurable: false, + writable: false, + value: true + }); + } else { + // Support old engines without Object.defineProperty + value.__dependenciesInjected__ = true; + } + // Get flat map of dependencies (possibly inherited) + const dependencies = this._mapDependencies(value); + // Inject into dependencies to create the object graph + for (let [key, id] of ObjectEntries(dependencies)) { + try { + if (isNil(value[key])) {value[key] = this.get(id, value);} + } catch (error) { + error.message += ` for {${key}: '${id}'} in <${value}>. Did you forget + to map it in your application?`; + throw error; + } + } + // Notify when dependencies are ready + if (!isNil(value.onDependenciesReady)) { + value.onDependenciesReady(); + } + } + + addProvider(name, provider) { + this._providers[name] = provider; + } + + getMappingFor(id) { + return this._mappings[id]; + } + + getIdForValue(value) { + for (let [id, mapping] of ObjectEntries(this._mappings)) { + if (mapping.getProvider().getValue() === value) { + return id; + } + } + return null; + } + + release(dependent) { + for (let mapping of ObjectValues(this._mappings)) { + if (mapping.hasDependent(dependent)) { + mapping.release(dependent); + } + } + } + + _mapDependencies(value, deps = {}) { + const Class = value.constructor || null; + const SuperClass = Class.__super__ || null; + // Recurse down the prototype chain + if (!isNil(SuperClass)) { + this._mapDependencies(SuperClass.constructor.prototype, deps); + } + if (isNil(value.dependencies)) {return deps;} + // Add dependencies of current value + for (let [key, id] of ObjectEntries(value.dependencies)) { + deps[key] = id; + } + return deps; + } + + _resolveValue(path) { + return Space.resolvePath(path); + } +} + +Injector.prototype.ERRORS = { + cannotMapUndefinedId() { + return 'Cannot map or .'; + }, + mappingExists(id) { + return `<${id}> would be overwritten. Use for that.`; + }, + noMappingFound(id) { + return `No mapping found for <${id}>`; + }, + cannotGetValueForUndefined() { + return "Cannot get injection mapping for ."; + } +}; + +// ========= PRIVATE CLASSES ========== + +class Mapping { + constructor(id, providers = {}) { + this._id = id; + this._provider = null; + this._dependents = []; + this._overrideInDependents = null; + + for (let [key, provider] of ObjectEntries(providers)) { + this[key] = this._setup(provider); + } + } + + toString() { + return 'Instance '; + } + + provide(dependent) { + // Register depented objects for this mapping so that their + // dependencies can overwritten later on. + if (!isNil(dependent) && !this.hasDependent(dependent)) { + this._dependents.push(dependent); + } + return this._provider.provide(); + } + + markForOverride() { + this._overrideInDependents = true; + } + + hasDependent(dependent) { + return this.getIndexOfDependee(dependent) > -1; + } + + getIndexOfDependee(dependent) { + return this._dependents.indexOf(dependent); + } + + release(dependent) { + this._dependents.splice(this.getIndexOfDependee(dependent), 1); + } + + getId() { + return this._id; + } + + getProvider() { + return this._provider; + } + + _setup(provider) { + return ((value) => { // We are inside an API call like + // injector.map('this').to('that') + // Set the provider of this mapping to what the API user chose + try { + this._provider = new provider(this._id, value); + } catch (error) { + error.message += ` could not be found! Maybe you forgot to include a file + in package.js?`; + throw error; + } + // Override the dependency in all dependent objects if this mapping is flagged + if (this._overrideInDependents) { + // Get the value from the provider + const providersValue = this._provider.provide(); + // Loop over the dependents + for (let dependent of ObjectValues(this._dependents)) { + // Loop over their dependencies and override the one this mapping + // is managing if it exists (it should) + const dependencies = dependent.dependencies || {}; + for (let [key, id] of ObjectEntries(dependencies)) { + if (id === this._id) { + dependent[key] = providersValue; + if (!isNil(dependent.onDependencyChanged)) { + dependent.onDependencyChanged(key, value); + } + } + } + } + } + // Reset the flag to override dependencies + this._overrideInDependents = false; + }); + } +} + +// ========= DEFAULT PROVIDERS ======== + +class Provider { + constructor(id = null, value = null) { + this._id = id; + this._value = value; + } + + getValue() { + return this._value; + } +} + +class ValueProvider extends Provider { + constructor(id, value) { + super(id, value); + if (isNil(this._value)) { + if (_.isString(this._id)) { + this._value = Space.resolvePath(this._id); + } else { + this._value = this._id; + } + } + } + + toString() { + return 'Instance '; + } + + provide() { + return this._value; + } +} + +class InstanceProvider extends Provider { + + toString() { + return 'Instance '; + } + + provide() { + return new this._value(); + } +} + +class SingletonProvider extends Provider { + constructor(id, value) { + super(id, value); + this.singleton = null; + if (isNil(this._value)) { + this._value = this._id; + } + if (_.isString(this._value)) { + this._value = Space.resolvePath(this._value); + } + } + + toString() { + return 'Instance '; + } + + provide() { + if (isNil(this._singleton)) { + this._singleton = new this._value(); + } + return this._singleton; + } +} + +Injector.DEFAULT_PROVIDERS = { + to: ValueProvider, + toStaticValue: ValueProvider, + asStaticValue: ValueProvider, + toClass: InstanceProvider, + toInstancesOf: InstanceProvider, + asSingleton: SingletonProvider, + toSingleton: SingletonProvider +}; + +export { + InjectionError, + Injector, + Provider, + ValueProvider, + InstanceProvider, + SingletonProvider +}; diff --git a/source/lib/underscore_deep_extend_mixin.js b/src/lib/underscore-deep-extend-mixin.js similarity index 94% rename from source/lib/underscore_deep_extend_mixin.js rename to src/lib/underscore-deep-extend-mixin.js index 7beb853..0fae5c8 100644 --- a/source/lib/underscore_deep_extend_mixin.js +++ b/src/lib/underscore-deep-extend-mixin.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; + // Deep object extend for underscore // As found on http://stackoverflow.com/a/29563346 diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..44d1127 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,102 @@ +import SpaceObject from './object.js'; +import {values as ObjectValues} from 'lodash'; +const Logger = SpaceObject.extend('Space.Logger', { + + STATES: { + stopped: 'stopped', + running: 'running' + }, + + Constructor() { + this._state = this.STATES.stopped; + this._adapters = {}; + }, + + addAdapter(id, adapter, shouldOverride = false) { + if (!id || typeof id !== 'string') { + throw new Error(this.constructor.ERRORS.invalidId); + } + if (this.hasAdapter(id) && !shouldOverride) { + throw new Error(this.constructor.ERRORS.mappingExists(id)); + } + this._adapters[id] = adapter; + }, + + overrideAdapter(id, adapter) { + return this.addAdapter(id, adapter, true); + }, + + getAdapter(id) { + return this._adapters[id] || null; + }, + + hasAdapter(id) { + return (this._adapters[id] !== null && this._adapters[id] !== undefined); + }, + + removeAdapter(id) { + if (this._adapters[id]) {delete this._adapters[id];} + }, + + getAdapters() { + return this._adapters; + }, + + start() { + if (this.isInState(this.STATES.stopped)) { + this._state = this.STATES.running; + } + }, + + stop() { + if (this.isInState(this.STATES.running)) { + this._state = this.STATES.stopped; + } + }, + + debug(...args) { + this._log('debug', args); + }, + + info(...args) { + this._log('info', args); + }, + + warning(...args) { + this._log('warning', args); + }, + + error(...args) { + this._log('error', args); + }, + + isInState(expectedState) { + return (this._state === expectedState); + }, + + isRunning() { + return this.isInState(this.STATES.running); + }, + + isStopped() { + return this.isInState(this.STATES.stopped); + }, + + _log(level, args) { + if (!this.isInState(this.STATES.running)) {return;} + + for (let adapter of ObjectValues(this.getAdapters())) { + adapter[level].apply(adapter, args); + } + } +}); + +Logger.ERRORS = { + mappingExists(id) { + return `Adapter with id '${id}' would be overwritten. Use method + 'overrideAdapter' for that`; + }, + invalidId: 'Cannot map or or non string values' +}; + +export default Logger; diff --git a/src/loggers/adapter.js b/src/loggers/adapter.js new file mode 100644 index 0000000..fc6ef7d --- /dev/null +++ b/src/loggers/adapter.js @@ -0,0 +1,46 @@ +import SpaceObject from '../object.js'; + +const LoggingAdapter = SpaceObject.extend('Space.Logger.LoggingAdapter', { + + _lib: null, + Constructor(lib) { + if (!lib) { + throw new Error(this.ERRORS.undefinedLibrary); + } + this.setLibrary(lib); + }, + + setLibrary(lib) { + this._lib = lib; + }, + + getLibrary() { + return this._lib || null; + }, + + debug(...args) { + this._log('debug', args); + }, + + info(...args) { + this._log('info', args); + }, + + warning(...args) { + this._log('warning', args); + }, + + error(...args) { + this._log('error', args); + }, + + _log(level, args) { + this._lib[level].apply(this._lib, args); + }, + + ERRORS: { + undefinedLibrary: 'Logging library is required' + } +}); + +export default LoggingAdapter; diff --git a/src/loggers/console-adapter.js b/src/loggers/console-adapter.js new file mode 100644 index 0000000..4d28e0a --- /dev/null +++ b/src/loggers/console-adapter.js @@ -0,0 +1,14 @@ +import LoggingAdapter from './adapter'; + +const ConsoleLogger = LoggingAdapter.extend('Space.Logger.ConsoleAdapter', { + + Constructor() { + LoggingAdapter.call(this, console); + }, + + warning(...args) { + return this._log('warn', args); + } +}); + +export default ConsoleLogger; diff --git a/src/meteor.js b/src/meteor.js new file mode 100644 index 0000000..dac6456 --- /dev/null +++ b/src/meteor.js @@ -0,0 +1,3 @@ +import {Space} from './index.js' ; + +this.Space = Space; diff --git a/src/module.js b/src/module.js new file mode 100644 index 0000000..e5338d4 --- /dev/null +++ b/src/module.js @@ -0,0 +1,290 @@ +import _ from 'underscore'; +import Logger from './logger.js'; +import {capitalize, isNil} from 'lodash'; +import SpaceObject from './object.js'; +require('./lib/underscore-deep-extend-mixin.js'); + +const Meteor = Meteor || undefined; +const Npm = Npm || undefined; + +const Module = SpaceObject.extend('Space.Module', { + + ERRORS: { + injectorMissing: 'Instance of Space.Injector needed to initialize module.' + }, + + configuration: {}, + requiredModules: null, + // An array of paths to classes that you want to become + // singletons in your application e.g: ['Space.messaging.EventBus'] + // these are automatically mapped and created on `app.run()` + singletons: [], + injector: null, + _state: 'constructed', + + Constructor(...args) { + SpaceObject.apply(this, args); + if (isNil(this.requiredModules)) { + this.requiredModules = []; + } + }, + + initialize(app, injector, isSubModule = false) { + this.app = app; + this.injector = injector; + + if (!this.is('constructed')) {return;} // Only initialize once + if (isNil(this.injector)) { + throw new Error(this.ERRORS.injectorMissing); + } + + this._state = 'configuring'; + + // Setup logger + if (!isSubModule) { + this.log = this._createLogger(); + } else { + this.log = this.injector.get('log'); + } + this.log.debug(`${this.constructor.publishedAs}: initialize`); + + // Setup basic mappings required by all modules if this the top-level module + if (!isSubModule) { + this.injector.map('Injector').to(this.injector); + this._mapSpaceServices(); + } + + // Setup required modules + for (let moduleId of this.requiredModules) { + // Create a new module instance if not already registered with the app + if (isNil(this.app.modules[moduleId])) { + const ModuleClass = Module.require(moduleId, this.constructor.name); + this.app.modules[moduleId] = new ModuleClass(); + // Initialize required module + const module = this.app.modules[moduleId]; + module.initialize(this.app, this.injector, true); + } + } + + // Merge in own configuration to give the chance for overwriting. + if (isSubModule) { + _.deepExtend(this.app.configuration, this.configuration); + this.configuration = this.app.configuration; + } else { + // The app can override all other modules + _.deepExtend(this.configuration, this.constructor.prototype.configuration); + } + + // Provide lifecycle hook before any initialization has been done + if (_.isFunction(this.beforeInitialize)) { + this.beforeInitialize(); + } + + // @backward {space:base} <= 4.1.3 for Meteor package, + // Give every module access Npm + if (!isNil(Meteor) && this._isServer() && !isNil(Npm)) { + this.npm = Npm; + } + + // Top-level module + if (!isSubModule) { + this.injector.map('configuration').to(this.configuration); + this._runOnInitializeHooks(); + this._autoMapSingletons(); + this._autoCreateSingletons(); + this._runAfterInitializeHooks(); + } + }, + + start() { + if (this.is('running')) {return;} + this._runLifeCycleAction('start'); + this._state = 'running'; + }, + + reset() { + // Don't allow reseting on production env + if (this._isServer() && this._isProduction()) {return;} + if (this._isResetting) {return;} + + const restartRequired = this.is('running'); + this._isResetting = true; + if (restartRequired) {this.stop();} + this._runLifeCycleAction('reset'); + if (restartRequired) {this.start();} + + // @compability {Meteor} for Meteor package + // There is no other way to avoid reset being called multiple times + // if multiple modules require the same sub-module. + if (!isNil(Meteor)) { + Meteor.defer(() => {this._isResetting = false;}); + } + }, + + stop() { + if (this.is('stopped')) {return;} + this._runLifeCycleAction('stop', () => {}); + this._state = 'stopped'; + }, + + is(expectedState) { + return expectedState === this._state; + }, + + // Invokes the lifecycle action on all required modules, then on itself, + // calling the instance hooks before, on, and after + _runLifeCycleAction(action, func) { + this._invokeActionOnRequiredModules(action); + this.log.debug(`${this.constructor.publishedAs}: ${action}`); + + if (_.isFunction(this[`before${capitalize(action)}`])) { + this[`before${capitalize(action)}`](); + } + if (_.isFunction(func)) { + func(); + } + if (_.isFunction(this[`on${capitalize(action)}`])) { + this[`on${capitalize(action)}`](); + } + if (_.isFunction(this[`after${capitalize(action)}`])) { + this[`after${capitalize(action)}`](); + } + }, + + // Provide lifecycle hook after this module was configured and injected + _runOnInitializeHooks() { + this._invokeActionOnRequiredModules('_runOnInitializeHooks'); + // Never run this hook twice + if (this.is('configuring')) { + this.log.debug(`${this.constructor.publishedAs}: onInitialize`); + this._state = 'initializing'; + // Inject required dependencies into this module + this.injector.injectInto(this); + // Call custom lifecycle hook if existant + if (_.isFunction(this.onInitialize)) { + this.onInitialize(); + } + } + }, + + _autoMapSingletons() { + this._invokeActionOnRequiredModules('_autoMapSingletons'); + if (this.is('initializing')) { + this.log.debug(`${this.constructor.publishedAs}: _autoMapSingletons`); + this._state = 'auto-mapping-singletons'; + // Map classes that are declared as singletons + for (let singleton of this.singletons) { + this.injector.map(singleton).asSingleton(); + } + } + }, + + _autoCreateSingletons() { + this._invokeActionOnRequiredModules('_autoCreateSingletons'); + if (this.is('auto-mapping-singletons')) { + this.log.debug(`${this.constructor.publishedAs}: _autoCreateSingletons`); + this._state = 'auto-creating-singletons'; + // Create singleton classes + for (let singleton of this.singletons) { + this.injector.create(singleton); + } + } + }, + + // After all modules in the tree have been configured etc. invoke last hook + _runAfterInitializeHooks() { + this._invokeActionOnRequiredModules('_runAfterInitializeHooks'); + // Never run this hook twice + if (this.is('auto-creating-singletons')) { + this.log.debug(`${this.constructor.publishedAs}: afterInitialize`); + this._state = 'initialized'; + // Call custom lifecycle hook if existant + if (_.isFunction(this.afterInitialize)) { + this.afterInitialize(); + } + } + }, + + _invokeActionOnRequiredModules(action) { + for (let moduleId of this.requiredModules) { + if (_.isFunction(this.app.modules[moduleId][action])) { + this.app.modules[moduleId][action](); + } + } + }, + + _wrapLifecycleHook(hook, wrapper) { + if (isNil(this[hook])) { + this[hook] = () => {}; + } + this[hook] = _.wrap(this[hook], wrapper); + }, + + _createLogger() { + const config = this._getLoggingConfig(this.configuration); + const logger = new Logger(); + if (config.enabled === true) { + logger.start(); + } + return logger; + }, + + _getLoggingConfig() { + let config = {}; + _.deepExtend(config, this.configuration); + _.deepExtend(config, this.constructor.prototype.configuration); + return config.log || {}; + }, + + _mapSpaceServices() { + this.injector.map('log').to(this.log); + }, + + _isServer() { + return !(typeof window !== 'undefined' && window.document); + }, + + _isProduction() { + return process.env.NODE_ENV === 'production'; + }, + + statics: { + // ========== STATIC MODULE MANAGEMENT ============ + + // All published modules register themselves here + published: {}, + + define(moduleName, prototype = {}) { + prototype.toString = () => moduleName; // For better debugging + return this.publish(Module.extend(moduleName, prototype), moduleName); + }, + + // Publishes a module into the space environment to make it + // visible and requireable for other modules and the application + publish(module, identifier) { + // TODO: its overriding name necessary? + // TypeError: Cannot assign to read only property 'name' of function 'function GrandchildModule() { initialize.apply(this, arguments); }' + // module.publishedAs = module.name = identifier; + module.publishedAs = identifier; + if (!isNil(Module.published[identifier])) { + throw new Error(`Two modules tried to be published as <${identifier}>`); + } else { + Module.published[identifier] = module; + return Module.published[identifier]; + } + }, + + // Retrieve a module by identifier + require(requiredModule, requestingModule) { + const module = Module.published[requiredModule]; + if (isNil(module)) { + throw new Error(`Could not find module <${requiredModule}> required by + <${requestingModule}>`); + } else { + return module; + } + } + } +}); + +export default Module; diff --git a/src/object.js b/src/object.js new file mode 100644 index 0000000..6ecc3a7 --- /dev/null +++ b/src/object.js @@ -0,0 +1,388 @@ +import _ from 'underscore'; +import {isNil, entries as ObjectEntries, values as ObjectValues, isPlainObject} from 'lodash'; +import {ensure, oneOf, anything} from 'simplecheck'; +import Space from './space.js'; +require('./lib/underscore-deep-extend-mixin.js'); + +const __extends__ = function(child, parent) { + for (let key of ObjectValues(this)) { + child[key] = parent[key]; + } + child.prototype = Object.create(parent.prototype); + child.__super__ = parent.prototype; + return child; +}; + +class SpaceObject { + // ============= PUBLIC PROTOTYPE ============== # + + // Assign given properties to the instance + constructor(properties) { + this._invokeConstructionCallbacks.apply(this, arguments); + // Copy properties to instance by default + for (let [key, value] of ObjectEntries(properties)) { + this[key] = value; + } + } + + onDependenciesReady() { + // Let mixins initialize themselves when dependencies are ready + for (let mixin of this.constructor._getAppliedMixins()) { + if (mixin.onDependenciesReady) {mixin.onDependenciesReady.call(this);} + } + } + + toString() { + return this.constructor.toString(); + } + + hasSuperClass() { + return (!isNil(this.constructor.__super__)); + } + + // Returns either the super class constructor (if no param given) or + // the prototype property or method with [key] + superClass(key) { + let sup = this.constructor.__super__.constructor; + if (!isNil(key)) { + return sup.prototype[key]; + } else { + return sup; + } + } + + // Returns true if the passed in mixin has been applied to this or a super class + hasMixin(mixin) { + return _.contains(this.constructor._getAppliedMixins(), mixin); + } + + // This method needs to stay separate from the constructor so that + // SpaceError can use it too! + _invokeConstructionCallbacks() { + if (isNil(this.constructor._getAppliedMixins)) { + return; + } + // Let mixins initialize themselves on construction + for (let mixin of this.constructor._getAppliedMixins()) { + if (mixin.onConstruction) {mixin.onConstruction.apply(this, arguments);} + } + } + + // ============= PUBLIC STATIC ============== # + + // Extends this class and return a child class with inherited prototype + // and static properties. + // + // There are various ways you can call this method: + // + // 1. SpaceObject.extend() + // -------------------------------------------- + // Creates an anonymous child class without extra prototype properties. + // Basically the same as `class extend SpaceObject` in coffeescript + // + // 2. SpaceObject.extend(className) + // -------------------------------------------- + // Creates a named child class without extra prototype properties. + // Basically the same as `class ClassName extend SpaceObject` in coffeescript + // + // 3. SpaceObject.extend(classPath) + // -------------------------------------------- + // Creates a child class with fully qualified class path like "my.custom.Class" + // assigned and registered internally so that Space.resolvePath can find it. + // This also assigns the class path as type, which can be used for serialization + // + // 4. SpaceObject.extend({ prop: 'first', … }) + // -------------------------------------------- + // Creates an anonymous child class with extra prototype properties. + // Same as: + // class extend SpaceObject + // prop: 'first' + // + // 5. SpaceObject.extend(namespace, className) + // -------------------------------------------- + // Creates a named class which inherits from SpaceObject and assigns + // it to the given namespace object. + // + // 6. SpaceObject.extend(className, prototype) + // -------------------------------------------- + // Creates a named class which inherits from SpaceObject and extra prototype + // properties which are assigned to the new class + // + // 7. SpaceObject.extend(classPath, prototype) + // -------------------------------------------- + // Creates a registered class which inherits from SpaceObject and extra prototype + // properties which are assigned to the new class + // + // 8. SpaceObject.extend(namespace, className, prototype) + // -------------------------------------------- + // Creates a named class which inherits from SpaceObject, has extra prototype + // properties and is assigned to the given namespace. + static extend(...args) { + // Defaults + let namespace = {}; + let classPath = null; + let className = '_Class'; // Same as coffeescript + let extension = {}; + + // Only one param: (extension) OR (className) OR (classPath) -> + if (args.length === 1) { + if (_.isObject(args[0])) { extension = args[0]; } + if (_.isString(args[0])) { + // (className) OR (classPath) + if (args[0].indexOf('.') !== -1) { + // classPath + classPath = args[0]; + className = classPath.substr(classPath.lastIndexOf('.') + 1); + } else { + // className + className = classPath = args[0]; + } + } + } + + // Two params must be: (namespace, className) OR (className, extension) -> + if (args.length === 2) { + if (_.isObject(args[0]) && _.isString(args[1])) { + namespace = args[0]; + className = args[1]; + extension = {}; + } else if (_.isString(args[0]) && _.isObject(args[1])) { + // (className) OR (classPath) + namespace = {}; + extension = args[1]; + if (args[0].indexOf('.') !== -1) { + // classPath + classPath = args[0]; + className = classPath.substr(classPath.lastIndexOf('.') + 1); + } else { + // className + className = classPath = args[0]; + } + } + } + + // All three params: (namespace, className, extension) -> + if (args.length === 3) { + namespace = args[0]; + className = args[1]; + extension = args[2]; + } + + ensure(namespace, oneOf(anything, Space.Namespace, Function)); + ensure(classPath, oneOf(String, null)); + ensure(className, String); + ensure(extension, anything); + + // Assign the optional custom constructor for this class + let Parent = this; + let Constructor = !isNil(extension.Constructor) ? + extension.Constructor : + function() { return Parent.apply(this, arguments); }; + + // Create a named constructor for this class so that debugging + // consoles are displaying the class name nicely. + let Child = new Function('initialize', `return function ${className}` + `() { \ + initialize.apply(this, arguments); \ + }`)(Constructor); + + // Add subclass to parent class + Parent._subClasses.push(Child); + + // Copy the static properties of this class over to the extended + for (let key in this) { Child[key] = this[key]; } + Child._subClasses = []; + + // Copy over static class properties defined on the extension + if (!isNil(extension.statics)) { + _.extend(Child, extension.statics); + delete extension.statics; + } + + // Extract mixins before they get added to prototype + let mixins = extension.mixin; + delete extension.mixin; + + // Extract onExtending callback and avoid adding it to prototype + let onExtendingCallback = extension.onExtending; + delete extension.onExtending; + + // Javascript prototypal inheritance "magic" + let Ctor = function() { + this.constructor = Child; + }; + Ctor.prototype = Parent.prototype; + Child.prototype = new Ctor(); + Child.__super__ = Parent.prototype; + + // Apply mixins + if (!isNil(mixins)) {Child.mixin(mixins);} + + // Merge the extension into the class prototype + this._mergeIntoPrototype(Child.prototype, extension); + + // Add the class to the namespace + if (!isNil(namespace)) { + namespace[className] = Child; + if (namespace instanceof Space.Namespace) { + classPath = `${namespace.getPath()}.${className}`; + } + } + + // Add type information to the class + if (!isNil(classPath)) {Child.type(classPath);} + + // Invoke the onExtending callback after everything has been setup + if (!isNil(onExtendingCallback)) { + onExtendingCallback.call(Child); + } + + return Child; + } + + static type(classPath) { + // Register this class with its class path + this.classPath = classPath; + Space.registry[this.classPath] = this; + try { + // Add the class to the resolved namespace + let path = this.classPath.substr(0, this.classPath.lastIndexOf('.')); + let namespace = Space.resolvePath(path); + let className = this.classPath.substr(this.classPath.lastIndexOf('.') + 1); + return namespace[className] = this; + } catch (error) {} + } + + static toString() { + return this.classPath; + } + + // Create and instance of the class that this method is called on + // e.g.: SpaceObject.create() would return an instance of SpaceObject + static create() { + // Use a wrapper class to hand the constructor arguments + // to the context class that #create was called on + let args = arguments; + let Context = this; + let wrapper = function() { return Context.apply(this, args); }; + __extends__(wrapper, Context); + return new wrapper(); + } + + // Mixin properties and methods to the class prototype and merge + // properties that are plain objects to support the mixin of configs etc. + static mixin(mixins) { + if (_.isArray(mixins)) { + return Array.from(mixins).map((mixin) => this._applyMixin(mixin)); + } else { + return this._applyMixin(mixins); + } + } + + // Returns true if this class has a super class + static hasSuperClass() { return (!isNil(this.__super__)); } + + static isSubclassOf(sup) { + let isSubclass = this.prototype instanceof sup; + let isSameClass = this === sup; + return isSubclass || isSameClass; + } + + // Returns either the super class constructor (if no param given) or + // the static property or method with [key] + static superClass(key) { + if (isNil(this.__super__)) { return undefined; } + let sup = this.__super__.constructor; + if (!isNil(key)) { return sup[key]; } else { return sup; } + } + + // Returns a flat, uniq array of all sub classes + static subClasses() { + let subs = [].concat(this._subClasses); + for (let subClass of subs) { + subs = subs.concat(subClass.subClasses()); + } + return _.uniq(subs); + } + + // Returns true if the passed in mixin has been applied to this or a super class + static hasMixin(mixin) { + return _.contains(this._getAppliedMixins(), mixin); + } + + static _applyMixin(mixin) { + // Add the original mixin to the registry so we can ask if a specific + // mixin has been added to a host class / instance + // Each class has its own mixins array + const hasMixins = !isNil(this._appliedMixins); + const areInherited = ( + hasMixins && this.superClass('_appliedMixins') === this._appliedMixins + ); + if (!hasMixins || areInherited) { + this._appliedMixins = []; + } + + // Keep the mixins array clean from duplicates + if (!_.contains(this._appliedMixins, mixin)) { + this._appliedMixins.push(mixin); + } + + // Create a clone so that we can remove properties without affecting the global + // mixin + const mixinCopy = _.clone(mixin); + + // Remove hooks from mixin, so that they are not added to host class + delete mixinCopy.onDependenciesReady; + delete mixinCopy.onConstruction; + + // Mixin static properties into the host class + if (!isNil(mixinCopy.statics)) { + const statics = mixinCopy.statics; + _.extend(this, statics); + for (let sub of this.subClasses()) { + _.extend(sub, statics); + } + delete mixinCopy.statics; + } + + // Give mixins the chance to do static setup when applied to the host class + if (!isNil(mixinCopy.onMixinApplied)) { + mixinCopy.onMixinApplied.call(this); + } + delete mixinCopy.onMixinApplied; + + // Copy over the mixin to the prototype and merge objects + this._mergeIntoPrototype(this.prototype, mixinCopy); + } + + static _getAppliedMixins() { + let mixins = []; + if (this.hasSuperClass() && !isNil(this.superClass()._getAppliedMixins)) { + mixins = mixins.concat(this.superClass()._getAppliedMixins()); + } + if (!isNil(this._appliedMixins)) { + mixins = mixins.concat(this._appliedMixins); + } + return _.uniq(mixins); + } + + static _mergeIntoPrototype(prototype, extension) { + for (let [key, value] of ObjectEntries(extension)) { + const hasProperty = prototype.hasOwnProperty(key); + if (hasProperty && isPlainObject(value) && isPlainObject(prototype[key])) { + // Deep extend plain objects + _.deepExtend(prototype[key], _.clone(value)); + } else { + if (isPlainObject(value)) { + value = _.clone(value); + } + // Set non-existing props and override existing methods + prototype[key] = value; + } + } + } +} + +SpaceObject.type('Space.Object'); +SpaceObject._subClasses = []; + +export default SpaceObject; diff --git a/src/space.js b/src/space.js new file mode 100644 index 0000000..a19b44e --- /dev/null +++ b/src/space.js @@ -0,0 +1,85 @@ +import {isNil, get, capitalize} from 'lodash'; +import _ from 'underscore'; + +class Namespace { + constructor(path) { + this._path = path; + } + getPath() { + return this._path; + } + toString() { + return this._path; + } +} + +// Define namespace for the space framework +const Space = new Namespace('Space'); +Space.Namespace = Namespace; +Space.registry = {}; + +// Not available on browsers +if (isNil(global)) { + let global = this; +} +global.Space = Space; +// Resolves a (possibly nested) path to a global object +// Returns the object or null (if not found) +Space.resolvePath = function(path) { + if (isNil(path)) {throw new Error(`Cannot resolve invalid path <${path}>`);} + if (path === '') {return global;} + + // If there is a direct reference just return it + if (Space.registry && !isNil(Space.registry[path])) { + return Space.registry[path]; + } + if (get(Space, `Module.published.${path}`)) { + return Space.Module.published[path]; + } + const parts = path.split('.'); + + let result = global; // Start with global namespace + for (let key of parts) { // Move down the object chain + result = get(result, key); + // Take published space modules into account + // to solve the Meteor package scoping problem + if (isNil(result)) { + throw new Error("Could not resolve path '" + path + "'"); + } + } + return result; +}; + +Space.namespace = function(id) { + Space.registry[id] = new Space.Namespace(id); + return Space.registry[id]; +}; + +// @backward {space:base} <= 4.1.3 +Space.capitalizeString = capitalize; + +Space.Dependency = function(propertyName, dependencyId) { + return function(target) { + const proto = target.prototype; + if (proto.dependencies && !proto.hasOwnProperty('Dependencies')) { + proto.dependencies = _.clone(proto.dependencies); + } + if (isNil(proto.dependencies)) {proto.dependencies = {};} + proto.dependencies[propertyName] = dependencyId || propertyName; + return target; + }; +}; + +Space.RequireModule = function(moduleId) { + return function(target) { + const proto = target.prototype; + if (proto.requiredModules && !proto.hasOwnProperty('RequiredModules')) { + proto.requiredModules = _.clone(proto.requiredModules); + } + if (isNil(proto.requiredModules)) {proto.requiredModules = [];} + proto.requiredModules.push(moduleId); + return target; + }; +}; + +export default Space; diff --git a/src/struct.js b/src/struct.js new file mode 100644 index 0000000..03d9910 --- /dev/null +++ b/src/struct.js @@ -0,0 +1,35 @@ +import _ from 'underscore'; +import {ensure} from 'simplecheck'; +import {isNil} from 'lodash'; +import SpaceObject from './object.js'; + +class Struct extends SpaceObject { + + constructor(data = {}) { + super(data); + this._checkFields(data); + } + + fields() { + return _.clone(this.constructor.fields) || {}; + } + + toPlainObject() { + const copy = {}; + for (let key of Object.keys(this.fields())) { + if (!isNil(this[key])) { + copy[key] = this[key]; + } + } + return copy; + } + + // Use the fields configuration to check given data during runtime + _checkFields(data) { + ensure(data, this.fields()); + } +} +Struct.fields = {}; +Struct.type('Space.Struct'); + +export default Struct; diff --git a/src/testing/bdd-api.js b/src/testing/bdd-api.js new file mode 100644 index 0000000..e665aae --- /dev/null +++ b/src/testing/bdd-api.js @@ -0,0 +1,40 @@ +import {isNil} from 'lodash'; +import Module from '../module.js'; +import Application from '../application.js'; + +const registeredBddApis = []; + +Module.registerBddApi = api => registeredBddApis.push(api); + +Module.test = Application.test = function(systemUnderTest, app = null) { + if (sisNil(ystemUnderTest)) { + throw new Error('Cannot test '); + } + let testApi = null; + const isModule = isSubclassOf(this, Module); + const isApplication = isSubclassOf(this, Application); + + // BDD API relies on dependency injection provided by Application + if (isNil(app)) { + if (isApplication) { + app = new this(); + } else { + app = new (Application.define('TestApp', { + configuration: { + appId: 'testApp' + }, + requiredModules: [this.publishedAs] + })); + } + } + for (let api of registeredBddApis) { + returnValue = api(app, systemUnderTest); + if (!isNil(returnValue)) { + testApi = returnValue; + } + } + if (isNil(testApi)) { + throw new Error(`No testing API found for ${systemUnderTest}`); + } + return testApi; +}; diff --git a/test.sh b/test.sh index 9bcd4a4..3ee5a6e 100755 --- a/test.sh +++ b/test.sh @@ -1,7 +1,24 @@ -#!/usr/bin/env bash +# !/usr/bin/env bash -if [ "$PORT" ]; then - meteor test-packages ./ --port $PORT +if [ "$TEST_DRIVER" ]; +then + TEST_DRIVER=$TEST_DRIVER else - meteor test-packages ./ + TEST_DRIVER="practicalmeteor:mocha" fi + +if [ "$METEOR_PACKAGE_DIRS" ]; +then + PACKAGE_DIRS=$METEOR_PACKAGE_DIRS +else + if [ "$PACKAGE_DIRS" ]; + then + PACKAGE_DIRS=$PACKAGE_DIRS + else + PACKAGE_DIRS="packages" + fi +fi + +export METEOR_PACKAGE_DIRS=$PACKAGE_DIRS + +eval "meteor test-packages ./ --driver-package $TEST_DRIVER $*" \ No newline at end of file diff --git a/test/common.js b/test/common.js new file mode 100644 index 0000000..5e7fbda --- /dev/null +++ b/test/common.js @@ -0,0 +1,14 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const sinonChai = require("sinon-chai"); +const spaceTesting = require('space-testing'); +const chaiExtensions = spaceTesting.chai; + +global.expect = chai.expect; +global.sinon = sinon; + +chai.use(sinonChai); +for (let key in chaiExtensions) { + chai.use(chaiExtensions[key]); +} + diff --git a/tests/integration/application_with_modules.spec.js b/test/integration/application-with-modules.spec.js similarity index 73% rename from tests/integration/application_with_modules.spec.js rename to test/integration/application-with-modules.spec.js index 5e5f24e..c0be2ea 100644 --- a/tests/integration/application_with_modules.spec.js +++ b/test/integration/application-with-modules.spec.js @@ -1,8 +1,10 @@ +import Module from '../../lib/module.js'; +import Application from '../../lib/application.js'; describe('Building applications based on modules', function() { beforeEach(function() { - Space.Module.published = {}; // reset published space modules + Module.published = {}; // reset published space modules }); it('loads required module correctly', function() { @@ -10,13 +12,13 @@ describe('Building applications based on modules', function() { let testValue = {}; let testResult = null; - Space.Module.define('FirstModule', { + Module.define('FirstModule', { onInitialize: function() { this.injector.map('testValue').to(testValue); } }); - Space.Application.create({ + Application.create({ requiredModules: ['FirstModule'], dependencies: { testValue: 'testValue' }, onInitialize: function() { testResult = this.testValue; } @@ -27,11 +29,11 @@ describe('Building applications based on modules', function() { it('configures module before running', function() { - let moduleValue = 'module configuration'; - let appValue = 'application configuration'; + const moduleValue = 'module configuration'; + const appValue = 'application configuration'; let testResult = null; - Space.Module.define('FirstModule', { + Module.define('FirstModule', { onInitialize: function() { this.injector.map('moduleValue').to(moduleValue); }, @@ -40,7 +42,7 @@ describe('Building applications based on modules', function() { } }); - let app = Space.Application.create({ + const app = Application.create({ requiredModules: ['FirstModule'], dependencies: { moduleValue: 'moduleValue' }, onInitialize: function() { diff --git a/tests/integration/lifecycle_hooks.tests.js b/test/integration/lifecycle-hooks.tests.js similarity index 68% rename from tests/integration/lifecycle_hooks.tests.js rename to test/integration/lifecycle-hooks.tests.js index 4aa44a0..d8c4aac 100644 --- a/tests/integration/lifecycle_hooks.tests.js +++ b/test/integration/lifecycle-hooks.tests.js @@ -1,15 +1,19 @@ +import _ from 'underscore'; +import Module from '../../lib/module.js'; +import Application from '../../lib/application.js'; + describe("Space.base - Application lifecycle hooks", function() { // TEST HELPERS - let addHookSpy = function(hooks, hookName) { + const addHookSpy = function(hooks, hookName) { hooks[hookName] = function() {}; sinon.spy(hooks, hookName); }; - let createLifeCycleHookSpies = function() { - hooks = {}; - hookNames = [ + const createLifeCycleHookSpies = function() { + const hooks = {}; + const hookNames = [ 'beforeInitialize', 'onInitialize', 'afterInitialize', 'beforeStart', 'onStart', 'afterStart', 'beforeReset', 'onReset', 'afterReset', 'beforeStop', 'onStop', 'afterStop' ]; @@ -19,9 +23,9 @@ describe("Space.base - Application lifecycle hooks", function() { return hooks; }; - let testOrderOfLifecycleHook = function(context, before, on, after) { - modules = ['firstHooks', 'secondHooks', 'appHooks']; - hooks = [before, on, after]; + const testOrderOfLifecycleHook = function(context, before, on, after) { + const modules = ['firstHooks', 'secondHooks', 'appHooks']; + const hooks = [before, on, after]; for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { expect(context[modules[i]][hooks[j]]).to.have.been.calledOnce; @@ -34,9 +38,9 @@ describe("Space.base - Application lifecycle hooks", function() { } }; - let expectHooksNotToBeCalledYet = function(context, before, on, after) { - modules = ['firstHooks', 'secondHooks', 'appHooks']; - hooks = [before, on, after]; + const expectHooksNotToBeCalledYet = function(context, before, on, after) { + const modules = ['firstHooks', 'secondHooks', 'appHooks']; + const hooks = [before, on, after]; for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { expect(context[modules[i]][hooks[j]]).not.to.have.been.called; @@ -44,37 +48,37 @@ describe("Space.base - Application lifecycle hooks", function() { } }; - beforeEach(function() { + beforeEach(() => { // reset published space modules - Space.Module.published = {}; + Module.published = {}; // Setup lifecycle hooks with sinon spys this.firstHooks = createLifeCycleHookSpies(); this.secondHooks = createLifeCycleHookSpies(); this.appHooks = createLifeCycleHookSpies(); // Create a app setup with two modules and use the spied apon hooks - Space.Module.define('First', this.firstHooks); - Space.Module.define('Second', _.extend(this.secondHooks, { requiredModules: ['First'] })); - this.app = Space.Application.create(_.extend(this.appHooks, { requiredModules: ['Second'] })); + Module.define('First', this.firstHooks); + Module.define('Second', _.extend(this.secondHooks, { requiredModules: ['First'] })); + this.app = Application.create(_.extend(this.appHooks, { requiredModules: ['Second'] })); }); - it("runs the initialize hooks in correct order", function() { + it("runs the initialize hooks in correct order", () => { testOrderOfLifecycleHook(this, 'beforeInitialize', 'onInitialize', 'afterInitialize'); }); - it("runs the start hooks in correct order", function() { + it("runs the start hooks in correct order", () => { expectHooksNotToBeCalledYet(this, 'beforeStart', 'onStart', 'afterStart'); this.app.start(); testOrderOfLifecycleHook(this, 'beforeStart', 'onStart', 'afterStart'); }); - it("runs the stop hooks in correct order", function() { + it("runs the stop hooks in correct order", () => { expectHooksNotToBeCalledYet(this, 'beforeStop', 'onStop', 'afterStop'); this.app.start(); this.app.stop(); testOrderOfLifecycleHook(this, 'beforeStop', 'onStop', 'afterStop'); }); - it("runs the reset hooks in correct order when app is running", function() { + it("runs the reset hooks in correct order when app is running", () => { expectHooksNotToBeCalledYet(this, 'beforeReset', 'onReset', 'afterReset'); this.app.start(); this.app.reset(); @@ -83,7 +87,7 @@ describe("Space.base - Application lifecycle hooks", function() { 'onStart', 'afterStart'); }); - it("runs the reset hooks in correct order when app is stopped", function() { + it("runs the reset hooks in correct order when app is stopped", () => { expectHooksNotToBeCalledYet(this, 'beforeReset', 'onReset', 'afterReset'); this.app.reset(); testOrderOfLifecycleHook(this, 'beforeReset', 'onReset', 'afterReset'); diff --git a/test/integration/module.regressions.js b/test/integration/module.regressions.js new file mode 100644 index 0000000..ba77711 --- /dev/null +++ b/test/integration/module.regressions.js @@ -0,0 +1,32 @@ +import Module from '../../lib/module.js'; +import SpaceObject from '../../lib/object.js'; +import {Injector} from '../../lib/injector.js'; +import Space from '../../lib/space.js'; + +describe("Module - regressions", function() { + + it("ensures autoboot singletons have access to injector mappings made in module onInitialize", function() { + const Test = Space.namespace('Test'); + + const SomeLib = { libMethod: function() {} }; + const singletonReadySpy = sinon.spy(); + const myInjector = new Injector(); + + SpaceObject.extend(Test, 'MySingleton', { + dependencies: { someLib: 'SomeLib' }, + onDependenciesReady: singletonReadySpy + }); + + Test.MyModule = Module.extend(Test, 'MyModule', { + singletons: ['Test.MySingleton'], + onInitialize() { this.injector.map('SomeLib').to(SomeLib); } + }); + + const module = new Test.MyModule(); + module.initialize(module, myInjector); + + expect(singletonReadySpy).to.have.been.called; + + }); + +}); diff --git a/tests/integration/requiring-modules.tests.js b/test/integration/requiring-modules.tests.js similarity index 60% rename from tests/integration/requiring-modules.tests.js rename to test/integration/requiring-modules.tests.js index e79bc18..9a9f221 100644 --- a/tests/integration/requiring-modules.tests.js +++ b/test/integration/requiring-modules.tests.js @@ -1,19 +1,21 @@ +import Module from '../../lib/module.js'; +import Application from '../../lib/application.js'; describe("Space.base - Requiring modules in other modules and apps", function() { it("multiple modules should be able to require the same base module", function() { - Space.Module.define('BaseModule', { + Module.define('BaseModule', { // Regression test -> this was invoked twice at some point afterInitialize: function() { this.injector.map('x').to('y'); } }); - Space.Module.define('DependentModule1', { requiredModules: ['BaseModule'] }); - Space.Module.define('DependentModule2', { requiredModules: ['BaseModule'] }); + Module.define('DependentModule1', { requiredModules: ['BaseModule'] }); + Module.define('DependentModule2', { requiredModules: ['BaseModule'] }); - const MyApp = Space.Application.define('MyApp', { + const MyApp = Application.define('MyApp', { requiredModules: ['DependentModule1', 'DependentModule2'] }); diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..cfe2e6d --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,7 @@ +--require ./test/common.js +--reporter spec +--ui bdd +--recursive +--colors +--timeout 6000 +--slow 100 \ No newline at end of file diff --git a/test/unit/application.unit.js b/test/unit/application.unit.js new file mode 100644 index 0000000..5ee3e1b --- /dev/null +++ b/test/unit/application.unit.js @@ -0,0 +1,122 @@ +import Application from '../../lib/application.js'; +import Module from '../../lib/module.js'; +import {Injector} from '../../lib/injector.js'; + +describe('Application', function() { + + beforeEach(() => { + // Reset published space modules + Module.published = {}; + }); + + it('extends Module', () => { + expect(Application.prototype).to.be.instanceof(Module); + }); + + describe('construction', () => { + it('initializes modules map as empty object', () => { + expect(new Application().modules).to.eql({}); + }); + + it('creates a new injector instance if none was given', () => { + expect(new Application().injector).to.be.instanceof(Injector); + }); + + it('uses the provided injector when given', () => { + const injector = new Injector(); + const application = new Application({injector: injector}); + expect(application.injector).to.equal(injector); + }); + + it('can also be created via static create method', () => { + const injector = new Injector(); + const application = Application.create({injector: injector}); + expect(application.injector).to.equal(injector); + expect(Application.create().injector).to.be.instanceof(Injector); + }); + + it('maps injector instance with itself', () => { + const injector = new Injector(); + const injectionMapping = { + to: sinon.spy(), + toInstancesOf: sinon.spy() + }; + injector.map = sinon.stub().returns(injectionMapping); + const application = new Application({injector: injector}); + + expect(injector.map).to.have.been.calledWithExactly('Injector'); + expect(injectionMapping.to).to.have.been.calledWithExactly(injector); + }); + + it('initializes the application', () => { + const initializeSpy = sinon.spy(Application.prototype, 'initialize'); + const application = new Application(); + expect(initializeSpy).to.have.been.calledOnce; + initializeSpy.restore(); + }); + + it('can be passed a configuration', () => { + const application = new Application({ + configuration: { + environment: 'testing' + } + }); + expect(application.configuration.environment).to.equal('testing'); + }); + + it('merges configurations of all modules and user options', () => { + Module.define('GrandchildModule', { + configuration: { + subModuleValue: 'grandChild', + grandchild: { + toChange: 'grandchildChangeMe', + toKeep: 'grandchildKeepMe' + } + } + }); + + Module.define('ChildModule', { + requiredModules: ['GrandchildModule'], + configuration: { + subModuleValue: 'child', + child: { + toChange: 'childChangeMe', + toKeep: 'childKeepMe' + } + } + }); + + Application.extend('TestApp', { + requiredModules: ['ChildModule'], + configuration: { + toChange: 'appChangeMe', + subModuleValue: 'overriddenByApp' + } + }); + + const app = new TestApp(); + app.configure({ + toChange: 'appNewValue', + child: { + toChange: 'childNewValue' + }, + grandchild: { + toChange: 'grandchildNewValue' + } + }); + + expect(app.injector.get('configuration')).to.be.sameAs({ + toChange: 'appNewValue', + subModuleValue: 'overriddenByApp', + child: { + toChange: 'childNewValue', + toKeep: 'childKeepMe' + }, + grandchild: { + toChange: 'grandchildNewValue', + toKeep: 'grandchildKeepMe' + } + }); + }); + }); +}); diff --git a/tests/unit/error.tests.js b/test/unit/error.unit.js similarity index 67% rename from tests/unit/error.tests.js rename to test/unit/error.unit.js index cd1bca2..b72b157 100644 --- a/tests/unit/error.tests.js +++ b/test/unit/error.unit.js @@ -1,6 +1,8 @@ -describe("Space.Error", function() { +import SpaceError from '../../lib/error.js'; - let MyError = Space.Error.extend('MyError', { +describe("SpaceError", function() { + + const MyError = SpaceError.extend('MyError', { message: 'The default message for this error' }); @@ -9,8 +11,8 @@ describe("Space.Error", function() { }); it("has same behavior as Space.Struct", function() { - let data = { message: 'test', code: 123 }; - let error = new MyError(data); + const data = { message: 'test', code: 123 }; + const error = new MyError(data); expect(error).to.be.instanceof(Error); expect(error).to.be.instanceof(MyError); expect(error.name).to.equal('MyError'); @@ -20,42 +22,42 @@ describe("Space.Error", function() { it("is easy to add additional fields", function() { MyError.fields = { customField: String }; - let data = { message: 'test', code: 123, customField: 'test' }; - let error = new MyError(data); + const data = { message: 'test', code: 123, customField: 'test' }; + const error = new MyError(data); expect(error.customField).to.equal('test'); MyError.fields = {}; }); it("throws the prototype message by default", function() { - let throwWithDefaultMessage = function() { + const throwWithDefaultMessage = function() { throw new MyError(); }; expect(throwWithDefaultMessage).to.throw(MyError.prototype.message); }); it("takes an optional message during construction", function() { - let myMessage = 'this is a custom message'; - let throwWithCustomMessage = function() { + const myMessage = 'this is a custom message'; + const throwWithCustomMessage = function() { throw new MyError(myMessage); }; expect(throwWithCustomMessage).to.throw(myMessage); }); it("includes a stack trace", function() { - error = new MyError(); + const error = new MyError(); expect(error.stack).to.be.a.string; }); describe("applying mixins", function() { it("supports mixin callbacks", function() { - let MyMixin = { + const MyMixin = { onConstruction: sinon.spy(), onDependenciesReady: sinon.spy() }; - let MyMixinError = Space.Error.extend('MyMixinError', { mixin: MyMixin }); - let param = 'test'; - let error = new MyMixinError(param); + const MyMixinError = SpaceError.extend('MyMixinError', { mixin: MyMixin }); + const param = 'test'; + const error = new MyMixinError(param); expect(MyMixin.onConstruction).to.have.been.calledOn(error); expect(MyMixin.onConstruction).to.have.been.calledWithExactly(param); }); diff --git a/test/unit/helpers.unit.js b/test/unit/helpers.unit.js new file mode 100644 index 0000000..075ec81 --- /dev/null +++ b/test/unit/helpers.unit.js @@ -0,0 +1,18 @@ +import {isNil} from 'lodash'; +import Space from '../../lib/space.js'; + +// Not available on browsers +if (isNil(global)) { + const global = this; +} + +describe('Space.resolvePath', function() { + + it('returns a deeply nested object', () => { + expect(Space.resolvePath('Space.Application')).to.equal(Space.Application); + }); + + it('returns the global context if path is empty', () => { + expect(Space.resolvePath('')).to.equal(global); + }); +}); diff --git a/tests/unit/injector_annotations.unit.js b/test/unit/injector-annotations.unit.js similarity index 96% rename from tests/unit/injector_annotations.unit.js rename to test/unit/injector-annotations.unit.js index 9772cdb..3b175f4 100644 --- a/tests/unit/injector_annotations.unit.js +++ b/test/unit/injector-annotations.unit.js @@ -1,4 +1,6 @@ -describe('Space.Injector annotations', function() { +import Space from '../../lib/space.js'; + +describe('Injector annotations', function() { describe('Dependency annotation', function() { diff --git a/test/unit/injector.unit.js b/test/unit/injector.unit.js new file mode 100644 index 0000000..8e6db3d --- /dev/null +++ b/test/unit/injector.unit.js @@ -0,0 +1,317 @@ +import {Injector} from '../../lib/injector.js'; +import SpaceObject from '../../lib/object.js'; +import Space from '../../lib/space.js'; + +describe('Injector', function() { + + beforeEach(() => { + this.injector = new Injector(); + }); + + // ============ MAPPINGS ============ + + describe('working with mappings', () => { + + it('injects into requested dependency', () => { + const myObject = {dependencies: {test: 'test'}}; + const testValue = {}; + this.injector.map('test').to(testValue); + this.injector.map('myObject').to(myObject); + + expect(this.injector.get('myObject').test).to.equal(testValue); + }); + + it(`throws error if mapping doesn't exist`, () => { + const id = 'blablub'; + expect(()=> this.injector.get(id)).to.throw( + this.injector.ERRORS.noMappingFound(id).message + ); + }); + + it('throws error if mapping would be overriden', () => { + this.injector.map('test').to('test'); + const override = () => this.injector.map('test').to('other'); + expect(override).to.throw(Error); + }); + + it('can remove existing mappings', () => { + this.injector.map('test').to('test'); + this.injector.remove('test'); + expect(()=> this.injector.get('test')).to.throw(Error); + }); + + it('provides an alias for getting values', () => { + this.injector.map('test').to('test'); + expect(this.injector.create('test')).to.equal('test'); + }); + + it('uses the toString method if its not a string id', () => { + class TestClass extends SpaceObject { + static toString() {return 'TestClass';} + } + + this.injector.map(TestClass).asSingleton(); + expect(this.injector.get('TestClass')).to.be.instanceof(TestClass); + }); + + it('throws error if you try to map undefined', () => { + expect(()=> this.injector.map(undefined)).to.throw( + this.injector.ERRORS.cannotMapUndefinedId() + ); + expect(()=> this.injector.map(null)).to.throw( + this.injector.ERRORS.cannotMapUndefinedId() + ); + }); + }); + + describe('overriding mappings', () => { + it('allows to override mappings', () => { + this.injector.map('test').to('test'); + this.injector.override('test').to('other'); + expect(this.injector.get('test')).to.equal('other'); + }); + + it('dynamically updates all dependent objects with the new dependency', () => { + const myObject = {dependencies: {test: 'test'}}; + const firstValue = {first: true}; + const secondValue = {second: true}; + this.injector.map('test').to(firstValue); + this.injector.injectInto(myObject); + expect(myObject.test).to.equal(firstValue); + this.injector.override('test').to(secondValue); + expect(myObject.test).to.equal(secondValue); + }); + + it('allows to de-register a dependent object from the mappings', () => { + const myObject = { + dependencies: { + first: 'First', + second: 'Second' + } + }; + const firstValue = { first: true }; + const secondValue = { second: true }; + this.injector.map('First').to(firstValue); + this.injector.map('Second').to(secondValue); + + this.injector.injectInto(myObject); + const firstMapping = this.injector.getMappingFor('First'); + const secondMapping = this.injector.getMappingFor('Second'); + expect(firstMapping.hasDependent(myObject)).to.be.true; + expect(secondMapping.hasDependent(myObject)).to.be.true; + // Release the reference to the dependent + this.injector.release(myObject); + expect(firstMapping.hasDependent(myObject)).to.be.false; + expect(secondMapping.hasDependent(myObject)).to.be.false; + }); + + it('tells the dependent object when a dependency changed', () => { + const dependentObject = { + dependencies: { + test: 'Test' + }, + onDependencyChanged: sinon.spy() + }; + const firstValue = {}; + const secondValue = {}; + this.injector.map('Test').to(firstValue); + this.injector.injectInto(dependentObject); + this.injector.override('Test').to(secondValue); + + expect(dependentObject.onDependencyChanged).to.have.been.calledWith( + 'test', secondValue + ); + }); + }); + + // ========== INJECTING DEPENDENCIES ========= + + describe('injecting dependencies', () => { + + it('injects static values', () => { + const value = {}; + this.injector.map('test').to(value); + const instance = SpaceObject.create({dependencies: {value: 'test'}}); + this.injector.injectInto(instance); + expect(instance.value).to.equal(value); + }); + + it('injects into provided dependencies', () => { + const first = {dependencies: {value: 'test'}}; + const second = {dependencies: {first: 'first'}}; + this.injector.map('test').to('value'); + this.injector.map('first').to(first); + + this.injector.injectInto(second); + expect(second.first).to.equal(first); + expect(first.value).to.equal('value'); + }); + + it('handles inherited dependencies', () => { + const Base = SpaceObject.extend({dependencies: {base: 'base'}}); + const Extended = Base.extend({dependencies: {extended: 'extended'}}); + this.injector.map('base').to('base'); + this.injector.map('extended').to('extended'); + + const instance = new Extended(); + this.injector.injectInto(instance); + expect(instance.base).to.equal('base'); + expect(instance.extended).to.equal('extended'); + }); + + it('never overrides existing properties', () => { + const instance = SpaceObject.create({ + dependencies: {test: 'test'}, + test: 'value' + }); + + this.injector.map('test').to('test'); + this.injector.injectInto(instance); + + expect(instance.test).to.equal('value'); + }); + + describe('when dependencies are ready', () => { + + it('tells the instance that they are ready', () => { + const value = 'test'; + const instance = SpaceObject.create({ + dependencies: {value: 'value'}, + onDependenciesReady: sinon.spy() + }); + + this.injector.map('value').to('value'); + this.injector.injectInto(instance); + this.injector.injectInto(instance); // shouldnt trigger twice; + + expect(instance.onDependenciesReady).to.have.been.calledOnce; + }); + + it('tells every single instance exactly once', () => { + const readySpy = sinon.spy(); + class TestClass extends SpaceObject { + constructor() { + super(); + this.dependencies = {value: 'test'}; + this.onDependenciesReady = readySpy; + } + } + + this.injector.map('test').to('test'); + this.injector.map('TestClass').toInstancesOf(TestClass); + + const first = this.injector.create('TestClass'); + const second = this.injector.create('TestClass'); + + expect(readySpy).to.have.been.calledTwice; + expect(readySpy).to.have.been.calledOn(first); + expect(readySpy).to.have.been.calledOn(second); + }); + }); + }); + + // ============ DEFAULT PROVIDERS ============ + + describe('default providers', () => { + + describe('static value providers', () => { + + it('maps to static value', () => { + const value = 'test'; + this.injector.map('first').to(value); + this.injector.map('second').toStaticValue(value); + + expect(this.injector.get('first')).to.equal(value); + expect(this.injector.get('second')).to.equal(value); + }); + + it('supports Space namespace lookup', () => { + Space.__test__ = {TestClass: SpaceObject.extend()}; + const path = 'Space.__test__.TestClass'; + this.injector.map(path).asStaticValue(); + + expect(this.injector.get(path)).to.equal(Space.__test__.TestClass); + delete Space.__test__; + }); + + it('can uses static toString method if available', () => { + class Test { + static toString() {return 'Test';} + } + + this.injector.map(Test).asStaticValue(); + expect(this.injector.get('Test')).to.equal(Test); + }); + }); + + describe('instance provider', () => { + + it('creates new instances for each request', () => { + class Test {} + this.injector.map('Test').toClass(Test); + + const first = this.injector.get('Test'); + const second = this.injector.get('Test'); + + expect(first).to.be.instanceof(Test); + expect(second).to.be.instanceof(Test); + expect(first).not.to.equal(second); + }); + }); + + describe('singleton provider', () => { + + it('maps class as singleton', () => { + class Test { + static toString() {return 'Test';} + } + this.injector.map(Test).asSingleton(); + const first = this.injector.get('Test'); + const second = this.injector.get('Test'); + + expect(first).to.be.instanceof(Test); + expect(first).to.equal(second); + }); + + it('maps id to singleton of class', () => { + class Test {} + this.injector.map('Test').toSingleton(Test); + const first = this.injector.get('Test'); + const second = this.injector.get('Test'); + + expect(first).to.be.instanceof(Test); + expect(first).to.equal(second); + }); + + it('looks up the value on Space namespace if only a path is given', () => { + Space.__test__ = {TestClass: SpaceObject.extend()}; + this.injector.map('Space.__test__.TestClass').asSingleton(); + + const first = this.injector.get('Space.__test__.TestClass'); + const second = this.injector.get('Space.__test__.TestClass'); + + expect(first).to.be.instanceof(Space.__test__.TestClass); + expect(first).to.equal(second); + delete Space.__test__; + }); + }); + }); + + // ============ CUSTOM PROVIDERS ============ + + describe('adding custom providers', () => { + it('adds the provider to the api', () => { + const loremIpsum = 'lorem ipsum'; + + this.injector.addProvider('toLoremIpsum', SpaceObject.extend({ + Constructor: function() { + this.provide = function() { + return loremIpsum; + }; + } + })); + this.injector.map('test').toLoremIpsum(); + expect(this.injector.get('test')).to.equal(loremIpsum); + }); + }); +}); diff --git a/test/unit/logger.unit.js b/test/unit/logger.unit.js new file mode 100644 index 0000000..5ea6039 --- /dev/null +++ b/test/unit/logger.unit.js @@ -0,0 +1,204 @@ +import Logger from '../../lib/logger.js'; +import LoggingAdapter from '../../lib/loggers/adapter.js'; +import SpaceObject from '../../lib/object.js'; + +const TestAdapter = LoggingAdapter.extend('TestAdapter', { + Constructor(lib) { + return this.setLibrary(lib); + } +}); + +describe("Logger", function() { + + beforeEach(() => { + this.lib = { + debug: sinon.spy(), + info: sinon.spy(), + warning: sinon.spy(), + error: sinon.spy() + }; + this.testAdapter = new TestAdapter(this.lib); + this.logger = new Logger(); + }); + + it('extends SpaceObject', () => { + expect(Logger.prototype).to.be.instanceof(SpaceObject); + }); + + it("is available of both client and server", () => { + expect(this.logger).to.be.instanceOf(Logger); + }); + + describe('adapters', () => { + it('throws error if id does not exists', () => { + const adapter = new TestAdapter(sinon.spy()); + expect(() => this.logger.addAdapter(undefined, adapter)).to.throw( + Logger.ERRORS.invalidId + ); + }); + + it('throws error if id is not a string value', () => { + const adapter = new TestAdapter(sinon.spy()); + expect(() => this.logger.addAdapter(adapter)).to.throw( + Logger.ERRORS.invalidId + ); + }); + + it('throws error if adapter would be overridden', () => { + const adapterId = 'testAdapter'; + const adapter = new TestAdapter(sinon.spy()); + + this.logger.addAdapter(adapterId, adapter); + expect(() => this.logger.addAdapter(adapterId, adapter)).to.throw( + Logger.ERRORS.mappingExists(adapterId) + ); + }); + + it('adds adapter', () => { + const adapterId = 'testAdapter'; + const adapter = new TestAdapter(sinon.spy()); + + this.logger.addAdapter(adapterId, adapter); + expect(this.logger.getAdapter(adapterId)).to.equal(adapter); + expect(this.logger.hasAdapter(adapterId)).to.be.true; + }); + + it('allows to override adapter', () => { + const adapterId = 'testAdapter'; + const adapter = new TestAdapter(sinon.spy()); + const overridingAdapter = new TestAdapter(sinon.spy()); + + this.logger.addAdapter(adapterId, adapter); + expect(() => { + this.logger.overrideAdapter(adapterId, overridingAdapter); + }).to.not.throw(Error); + expect(this.logger.getAdapter(adapterId)).to.equal(overridingAdapter); + }); + + it('resolves adapter by id', () => { + const consoleAdapter = new TestAdapter(sinon.spy()); + const fileAdapter = new TestAdapter(sinon.spy()); + + this.logger.addAdapter('console', consoleAdapter); + this.logger.addAdapter('file', fileAdapter); + expect(this.logger.getAdapter('console')).to.equal(consoleAdapter); + expect(this.logger.getAdapter('file')).to.equal(fileAdapter); + expect(this.logger.getAdapter('non-existing-adapter')).to.be.null; + }); + + it('removes adapter', () => { + const adapterId = 'testAdapter'; + const adapter = new TestAdapter(sinon.spy()); + + this.logger.addAdapter(adapterId, adapter); + this.logger.removeAdapter(adapterId); + expect(this.logger.getAdapter(adapterId)).to.be.null; + expect(this.logger.hasAdapter(adapterId)).to.be.false; + }); + + it('returns adapters', () => { + const adapters = { + console: new TestAdapter(sinon.spy()), + file: new TestAdapter(sinon.spy()) + }; + this.logger.addAdapter('console', adapters.console); + this.logger.addAdapter('file', adapters.file); + expect(this.logger.getAdapters()).to.be.eql(adapters); + }); + }); + + it("only logs after starting", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + const message = 'My log message'; + + expect(this.logger.isRunning()).to.be.false; + expect(this.logger.isStopped()).to.be.true; + this.logger.info(message); + expect(this.lib.info).to.not.be.called; + + this.logger.start(); + expect(this.logger.isRunning()).to.be.true; + expect(this.logger.isStopped()).to.be.false; + this.logger.info(message); + expect(this.lib.info).to.be.calledOnce; + expect(this.lib.info.calledWith(message)).to.be.true; + }); + + it("allows logging output to be stopped", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + const message = 'My log message'; + + expect(this.logger.isRunning()).to.be.false; + expect(this.logger.isStopped()).to.be.true; + this.logger.start(); + expect(this.logger.isRunning()).to.be.true; + expect(this.logger.isStopped()).to.be.false; + this.logger.info(message); + expect(this.lib.info.calledWith(message)).to.be.true; + + this.logger.stop(); + expect(this.logger.isRunning()).to.be.false; + expect(this.logger.isStopped()).to.be.true; + + this.logger.info(message); + expect(this.lib.info).to.not.be.calledTwice; + }); + + describe('logging', () => { + it('allows multiple logging adapters to log same message', () => { + const firstLib = {debug: sinon.spy()}; + const firstAdapter = new TestAdapter(firstLib); + const secondLib = {debug: sinon.spy()}; + const secondAdapter = new TestAdapter(secondLib); + const message = 'My log message'; + + this.logger.addAdapter('first', firstAdapter); + this.logger.addAdapter('second', secondAdapter); + this.logger.start(); + + this.logger.debug(message); + expect(firstLib.debug.calledWith(message)).to.be.true; + expect(firstLib.debug).to.be.calledOnce; + expect(secondLib.debug.calledWith(message)).to.be.true; + expect(secondLib.debug).to.be.calledOnce; + }); + + describe('logs message as', () => { + it("debug", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + this.logger.start(); + + const message = 'My log message'; + this.logger.debug(message); + expect(this.lib.debug.calledWith(message)).to.be.true; + }); + + it("info", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + this.logger.start(); + + const message = 'My log message'; + this.logger.info(message); + expect(this.lib.info.calledWith(message)).to.be.true; + }); + + it("warning", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + this.logger.start(); + + const message = 'My log message'; + this.logger.warning(message); + expect(this.lib.warning.calledWith(message)).to.be.true; + }); + + it("error", () => { + this.logger.addAdapter('my-logger', this.testAdapter); + this.logger.start(); + + const message = 'My log message'; + this.logger.error(message); + expect(this.lib.error.calledWith(message)).to.be.true; + }); + }); + }); +}); diff --git a/test/unit/module.unit.js b/test/unit/module.unit.js new file mode 100644 index 0000000..abf725b --- /dev/null +++ b/test/unit/module.unit.js @@ -0,0 +1,206 @@ +import SpaceObject from '../../lib/object.js'; +import Module from '../../lib/module.js'; +import {Injector} from '../../lib/injector.js'; + +describe('Module', function() { + + beforeEach(() => { + // Reset published space modules + Module.published = {}; + }); + + it('extends space object', () => { + expect(Module.prototype).to.be.instanceof(SpaceObject); + }); + + describe('static publish', () => { + + it('adds given module to the static collection of published modules', () => { + const module = Module.define('test'); + expect(Module.published.test).to.equal(module); + }); + + it('throws an error if two modules try to publish under same name', () => { + const publishTwoModulesWithSameName = () => { + Module.define('test'); + Module.define('test'); + }; + expect(publishTwoModulesWithSameName).to.throw(Error); + }); + }); + + describe('static require', () => { + it('returns published module for given identifier', () => { + const module = Module.define('test'); + const requiredModule = Module.require('test'); + expect(requiredModule).to.equal(module); + }); + + it('throws and error if no module was registered for given identifier', () => { + const requireUnkownModule = () => {Module.require('unknown module');}; + expect(requireUnkownModule).to.throw(Error); + }); + }); + + describe('constructor', () => { + it('sets required modules to empty array if none defined', () => { + const module = new Module(); + expect(module.requiredModules).to.be.instanceof(Array); + expect(module.requiredModules).to.be.empty; + }); + + it('leaves the defined required modules intact', () => { + const testArray = []; + const module = Module.create({requiredModules: testArray}); + expect(module.requiredModules).to.equal(testArray); + }); + + it('sets the correct state', () => { + const module = new Module(); + expect(module.is('constructed')).to.be.true; + }); + }); +}); + +describe('Module - #initialize', function() { + + beforeEach(() => { + // Reset published space modules + Module.published = {}; + this.injector = new Injector(); + sinon.spy(this.injector, 'injectInto'); + this.module = new Module(); + // faked required modules to spy on + this.SubModule1 = Module.define('SubModule1'); + this.SubModule2 = Module.define('SubModule2'); + this.app = {modules: {}}; + }); + + it('asks the injector to inject dependencies into the module', () => { + this.module.initialize(this.app, this.injector); + expect(this.injector.injectInto).to.have.been.calledWith(this.module); + }); + + it('throws an error if no injector is provided', () => { + const initializeWithoutInjector = () => this.module.initialize(); + expect(initializeWithoutInjector).to.throw(Error); + }); + + it('sets the initialized flag correctly', () => { + this.module.initialize(this.app, this.injector); + expect(this.module.is('initialized')).to.be.true; + }); + + xit('server adds Npm as property to the module', () => { + this.module.initialize(this.app, this.injector); + expect(this.module.npm.require).to.be.defined; + }); + + it('invokes the onInitialize method on itself', () => { + this.module.onInitialize = sinon.spy(); + this.module.initialize(this.app, this.injector); + expect(this.module.onInitialize).to.have.been.calledOnce; + }); + + it('creates required modules and adds them to the app', () => { + this.module.requiredModules = [this.SubModule1.name, this.SubModule2.name]; + this.module.initialize(this.app, this.injector); + expect(this.app.modules[this.SubModule1.name]).to.be.instanceof(this.SubModule1); + expect(this.app.modules[this.SubModule2.name]).to.be.instanceof(this.SubModule2); + }); + + it('initializes required modules', () => { + sinon.stub(this.SubModule1.prototype, 'initialize'); + this.module.requiredModules = [this.SubModule1.name]; + this.module.initialize(this.app, this.injector); + expect(this.SubModule1.prototype.initialize).to.have.been.calledOnce; + }); + + it('can only be initialized once', () => { + this.module.onInitialize = sinon.spy(); + this.module.initialize(this.app, this.injector); + this.module.initialize(this.app, this.injector); + expect(this.module.onInitialize).to.have.been.calledOnce; + }); +}); + +describe('Module - #start', function() { + + beforeEach(() => { + this.module = new Module(); + this.module.log = {debug: sinon.spy()}; + this.module.start(); + this.module._runLifeCycleAction = sinon.spy(); + }); + + it('sets the state to running', () => { + expect(this.module.is('running')).to.be.true; + }); + + it('ignores start calls on a running module', () => { + this.module.start(); + expect(this.module._runLifeCycleAction).not.to.have.been.called; + }); +}); + +describe('Module - #stop', function() { + + beforeEach(() => { + this.module = new Module(); + this.module.log = {debug: sinon.spy()}; + this.module.start(); + this.module.stop(); + this.module._runLifeCycleAction = sinon.spy(); + }); + + it('sets the state to stopped', () => { + expect(this.module.is('stopped')).to.be.true; + }); + + it('ignores stop calls on a stopped module', () => { + this.module.stop(); + expect(this.module._runLifeCycleAction).not.to.have.been.called; + }); +}); + +describe('Module - #reset', function() { + + beforeEach(() => { + this.module = new Module(); + this.module.log = {debug: sinon.spy()}; + this.module._runLifeCycleAction = sinon.spy(); + }); + + xit('rejects attempts to reset when in production', () => { + const nodeEnvBackup = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + this.module.reset(); + process.env.NODE_ENV = nodeEnvBackup; + expect(this.module._runLifeCycleAction).not.to.have.been.called; + }); +}); + +describe("Module - wrappable lifecycle hooks", function() { + it("allows mixins to hook into the module lifecycle", () => { + const moduleOnInitializeSpy = sinon.spy(); + const mixinOnInitializeSpy = sinon.spy(); + const MyModule = Module.extend({ + onInitialize: moduleOnInitializeSpy + }); + + MyModule.mixin({ + onDependenciesReady: function() { + this._wrapLifecycleHook('onInitialize', function(onInitialize) { + onInitialize.call(this); + return mixinOnInitializeSpy.call(this); + }); + } + }); + const module = new MyModule(); + module.initialize(module, new Injector()); + + expect(moduleOnInitializeSpy).to.have.been.calledOnce; + expect(mixinOnInitializeSpy).to.have.been.calledOnce; + expect(moduleOnInitializeSpy).to.have.been.calledBefore(mixinOnInitializeSpy); + }); +}); diff --git a/test/unit/object.unit.js b/test/unit/object.unit.js new file mode 100644 index 0000000..215fb1b --- /dev/null +++ b/test/unit/object.unit.js @@ -0,0 +1,356 @@ +import SpaceObject from '../../lib/object.js'; +import Space from '../../lib/space.js'; + +describe('SpaceObject', function() { + + beforeEach(() => { + this.namespace = {}; + }); + + describe('extending', () => { + + it('creates and returns a subclass', () => { + SpaceObject.extend(this.namespace, 'MyClass'); + expect(this.namespace.MyClass).to.extend(SpaceObject); + }); + + it('applies the arguments to the super constructor', () => { + const first = 'first'; + const second = 2; + const third = {}; + const spy = sinon.spy(); + SpaceObject.extend(this.namespace, 'Base', { + Constructor() {spy.apply(this, arguments);} + }); + this.namespace.Base.extend(this.namespace, 'Extended'); + const instance = new this.namespace.Extended(first, second, third); + expect(spy).to.have.been.calledWithExactly(first, second, third); + expect(spy).to.have.been.calledOn(instance); + }); + + it('allows to extend the prototype', () => { + const First = SpaceObject.extend({ + first: 1, + get: function(property) { + return this[property]; + } + }); + + const Second = First.extend({ + second: 2, + get: function() { + return First.prototype.get.apply(this, arguments); + } + }); + + class Third extends Second { + get(property) { + return super.get(property); + } + } + + const instance = new Third(); + expect(instance.get('first')).to.equal(1); + expect(instance.get('second')).to.equal(2); + }); + + describe("providing fully qualified class path", () => { + + it("registers the class for internal lookup", () => { + Space.namespace('My.custom'); + const FirstClass = SpaceObject.extend('My.custom.FirstClass', {}); + const SecondClass = SpaceObject.extend('My.custom.SecondClass', {}); + expect(Space.resolvePath('My.custom.FirstClass')).to.equal(FirstClass); + expect(Space.resolvePath('My.custom.SecondClass')).to.equal(SecondClass); + }); + + it("assigns the class path", () => { + const className = 'My.custom.Class'; + const MyClass = SpaceObject.extend(className); + expect(MyClass.toString()).to.equal(className); + expect(new MyClass().toString()).to.equal(className); + }); + + it("exposes the class on the global scope if possible", () => { + const my = {}; + my.namespace = Space.namespace('my.namespace'); + const MyClass = SpaceObject.extend('my.namespace.MyClass'); + expect(my.namespace.MyClass).to.equal(MyClass); + }); + + it("works correctly without nested namespaces", () => { + const MyClass = SpaceObject.extend('MyClass'); + expect(Space.resolvePath('MyClass')).to.equal(MyClass); + }); + }); + + describe("working with static class properties", () => { + + it('allows you to define static class properties', () => { + const myStatics = {}; + const MyClass = SpaceObject.extend({statics: {myStatics: myStatics}}); + expect(MyClass.myStatics).to.equal(myStatics); + }); + + it('provides an api for defining a callback while extending', () => { + const onExtendingSpy = sinon.spy(); + const MyClass = SpaceObject.extend({onExtending: onExtendingSpy}); + expect(onExtendingSpy).to.have.been.calledOn(MyClass); + }); + }); + }); + + describe('creating instances', () => { + it('creates a new instance of given class', () => { + expect(SpaceObject.create()).to.be.instanceof(SpaceObject); + }); + + it('allows to initialize the instance with given properties', () => { + const instance = SpaceObject.create({ + first: 1, + get(property) {return this[property];} + }); + expect(instance.get('first')).to.equal(1); + }); + + it('forwards any number of arguments to the constructor', () => { + const Base = SpaceObject.extend({ + Constructor: function(first, second) { + this.first = first; + this.second = second; + } + }); + const instance = Base.create(1, 2); + expect(instance.first).to.equal(1); + expect(instance.second).to.equal(2); + }); + }); + + describe("inheritance helpers", () => { + const Base = SpaceObject.extend({ + statics: { + prop: 'static', + method: function() {} + }, + prop: 'prototype', + method: function() {} + }); + const Sub = Base.extend(); + const GrandSub = Sub.extend(); + + describe("static", () => { + + it("can tell if there is a super class", () => { + expect(Sub.hasSuperClass()).to.be.true; + }); + + it("can return the super class", () => { + expect(Sub.superClass()).to.equal(Base); + }); + + it("returns undefined if there is no super class", () => { + expect(SpaceObject.superClass()).to.equal(undefined); + }); + + it("can return a static prop or method of the super class", () => { + expect(Sub.superClass('prop')).to.equal(Base.prop); + expect(Sub.superClass('method')).to.equal(Base.method); + }); + + it("can give back a flat array of sub classes", () => { + expect(Base.subClasses()).to.eql([Sub, GrandSub]); + expect(Sub.subClasses()).to.eql([GrandSub]); + expect(GrandSub.subClasses()).to.eql([]); + }); + }); + + describe("prototype", () => { + it("can tell if there is a super class", () => { + expect(new Sub().hasSuperClass()).to.be.true; + }); + + it("can return the super class", () => { + expect(new Sub().superClass()).to.equal(Base); + }); + + it("can return a static prop or method of the super class", () => { + expect(new Sub().superClass('prop')).to.equal(Base.prototype.prop); + expect(new Sub().superClass('method')).to.equal(Base.prototype.method); + }); + }); + }); + + describe('mixins', () => { + + it('adds methods to the prototype', () => { + const testMixin = { + test() {} + }; + const TestClass = SpaceObject.extend(); + TestClass.mixin(testMixin); + expect(TestClass.prototype.test).to.equal(testMixin.test); + }); + + it('overrides existing methods of the prototype', () => { + const testMixin = { + test: function() {} + }; + const TestClass = SpaceObject.extend({ + test: function() {} + }); + TestClass.mixin(testMixin); + expect(TestClass.prototype.test).to.equal(testMixin.test); + }); + + it('merges object properties', () => { + const testMixin = { + dependencies: { + second: 'second' + } + }; + + const TestClass = SpaceObject.extend({ + dependencies: { + first: 'first' + } + }); + TestClass.mixin(testMixin); + expect(TestClass.prototype.dependencies.first).to.equal('first'); + expect(TestClass.prototype.dependencies.second).to.equal('second'); + }); + + it("does not modify other mixins when merging properties", () => { + const FirstMixin = { + dependencies: { + firstMixin: 'onExtending' + } + }; + const FirstClass = SpaceObject.extend({ + mixin: [FirstMixin], + dependencies: { + first: 'first' + } + }); + FirstClass.mixin({ + dependencies: { + firstMixin: 'afterExtending' + } + }); + expect(FirstMixin).to.be.sameAs({dependencies: {firstMixin: 'onExtending'}}); + expect(FirstClass.prototype.dependencies).to.be.sameAs({ + first: 'first', + firstMixin: 'afterExtending' + }); + }); + + it("can provide a hook that is called when the mixin is applied", () => { + const myMixin = {onMixinApplied: sinon.spy()}; + const TestClass = SpaceObject.extend(); + TestClass.mixin(myMixin); + expect(myMixin.onMixinApplied).to.have.been.calledOnce; + }); + + it('can be defined as prototype property when extending classes', () => { + const myMixin = { onMixinApplied: sinon.spy() }; + const MyClass = SpaceObject.extend({mixin: [myMixin]}); + expect(myMixin.onMixinApplied).to.have.been.calledOn(MyClass); + }); + + it('can be used to mixin static properties on to the class', () => { + const myMixin = {statics: { myMethod: sinon.spy() }}; + const MyClass = SpaceObject.extend({mixin: [myMixin]}); + MyClass.myMethod(); + expect(myMixin.statics.myMethod).to.have.been.calledOn(MyClass); + }); + + it('can be checked which mixins a class has', () => { + const FirstMixin = {}; + const SecondMixin = {}; + const ThirdMixin = {}; + + const MyClass = SpaceObject.extend({mixin: FirstMixin}); + MyClass.mixin(SecondMixin); + const instance = new MyClass(); + // Static checks + expect(MyClass.hasMixin(FirstMixin)).to.be.true; + expect(MyClass.hasMixin(SecondMixin)).to.be.true; + expect(MyClass.hasMixin(ThirdMixin)).to.be.false; + // Instance checks + expect(instance.hasMixin(FirstMixin)).to.be.true; + expect(instance.hasMixin(SecondMixin)).to.be.true; + expect(instance.hasMixin(ThirdMixin)).to.be.false; + }); + + describe("mixin inheritance", () => { + + it("does not apply mixins to super classes", () => { + const firstMixin = {}; + const secondMixin = {}; + const SuperClass = SpaceObject.extend({mixin: firstMixin}); + const SubClass = SuperClass.extend({mixin: secondMixin}); + expect(SuperClass.hasMixin(firstMixin)).to.be.true; + expect(SuperClass.hasMixin(secondMixin)).to.be.false; + expect(SubClass.hasMixin(firstMixin)).to.be.true; + expect(SubClass.hasMixin(secondMixin)).to.be.true; + }); + + it("inherits mixins to children when added to base class later on", () => { + const LateMixin = { statics: { test: 'property' } }; + // Base class with a mixin + const BaseClass = SpaceObject.extend(); + // Sublcass with its own mixin + const SubClass = BaseClass.extend(); + // Later we extend base class + BaseClass.mixin(LateMixin); + // Sub class should have all three mixins correctly applied + expect(SubClass.hasMixin(LateMixin)).to.be.true; + expect(SubClass.test).to.equal(LateMixin.statics.test); + }); + }); + + describe("onDependenciesReady hooks", () => { + + it("can provide a hook that is called when dependencies of host class are ready", () => { + const myMixin = {onDependenciesReady: sinon.spy()}; + const TestClass = SpaceObject.extend(); + TestClass.mixin(myMixin); + new TestClass().onDependenciesReady(); + expect(myMixin.onDependenciesReady).to.have.been.calledOnce; + }); + + it("inherits the onDependenciesReady hooks to sub classes", () => { + const firstMixin = {onDependenciesReady: sinon.spy()}; + const secondMixin = {onDependenciesReady: sinon.spy()}; + const SuperClass = SpaceObject.extend(); + SuperClass.mixin(firstMixin); + const SubClass = SuperClass.extend(); + SubClass.mixin(secondMixin); + new SubClass().onDependenciesReady(); + expect(firstMixin.onDependenciesReady).to.have.been.calledOnce; + expect(secondMixin.onDependenciesReady).to.have.been.calledOnce; + }); + + it("calls inherited mixin hooks only once per chain", () => { + const myMixin = {onDependenciesReady: sinon.spy()}; + const SuperClass = SpaceObject.extend(); + SuperClass.mixin(myMixin); + const SubClass = SuperClass.extend(); + new SubClass().onDependenciesReady(); + expect(myMixin.onDependenciesReady).to.have.been.calledOnce; + }); + }); + + describe("construction hooks", () => { + + it("can provide a hook that is called on construction of host class", () => { + const myMixin = {onConstruction: sinon.spy()}; + const TestClass = SpaceObject.extend(); + TestClass.mixin(myMixin); + const first = {}; + const second = {}; + new TestClass(first, second); + expect(myMixin.onConstruction).to.have.been.calledWithExactly(first, second); + }); + }); + }); +}); diff --git a/test/unit/struct.unit.js b/test/unit/struct.unit.js new file mode 100644 index 0000000..3ff2c16 --- /dev/null +++ b/test/unit/struct.unit.js @@ -0,0 +1,84 @@ +import {MatchError, Integer} from 'simplecheck'; +import Struct from '../../lib/struct.js'; +import SpaceObject from '../../lib/object.js'; + +describe('Struct', function() { + + const MyMixin = { + onConstruction: sinon.spy() + }; + + class MyTestStruct extends Struct { + fields() { + return {name: String, age: Integer}; + } + } + MyTestStruct.mixin([MyMixin]); + + class MyExtendedTestStruct extends MyTestStruct { + fields() { + const fields = super.fields(); + fields.extra = Integer; + return fields; + } + } + + it("is a SpaceObject", () => { + expect(Struct.prototype).to.be.instanceof(SpaceObject); + }); + + it("calls the super constructor", () => { + const data = {name: 'Dominik', age: 26}; + const struct = new MyTestStruct(data); + expect(MyMixin.onConstruction).to.have.been.calledWithExactly(data); + expect(MyMixin.onConstruction).to.have.been.calledOn(struct); + }); + + describe('defining fields', () => { + it('assigns the properties to the instance', () => { + const properties = {name: 'Dominik', age: 26}; + const instance = new MyTestStruct(properties); + expect(instance).to.be.sameAs(properties); + }); + + it('provides a method to cast to plain object', () => { + const instance = new MyTestStruct({name: 'Dominik', age: 26}); + const copy = instance.toPlainObject(); + expect(copy.name).to.equal('Dominik'); + expect(copy.age).to.equal(26); + expect(copy).to.be.an.object; + expect(copy).not.to.be.instanceof(MyTestStruct); + }); + + it('throws a match error if a property is of wrong type', () => { + const properties = {name: 5, age: 26}; + expect(() => new MyTestStruct(properties)).to.throw(MatchError); + }); + + it('throws a match error if additional properties are given', () => { + const properties = {name: 5, age: 26, extra: 0}; + expect(() => new MyTestStruct(properties)).to.throw(MatchError); + }); + + it('throws a match error if a property is missing', () => { + const properties = {name: 5}; + expect(() => new MyTestStruct(properties)).to.throw(MatchError); + }); + + it('allows to extend the fields of base classes', () => { + const properties = {name: 'test', age: 26, extra: 0}; + expect(() => new MyExtendedTestStruct(properties)).not.to.throw(MatchError); + }); + + // TODO: remove when breaking change is made for next major version: + it('stays backward compatible with static fields api', () => { + class StaticFieldsStruct extends Struct {} + StaticFieldsStruct.fields = {name: String, age: Integer}; + + const properties = {name: 'Dominik', age: 26}; + const instance = new StaticFieldsStruct(properties); + expect(instance).to.be.sameAs(properties); + expect(() => new StaticFieldsStruct({name: 5})).to.throw(MatchError); + }); + }); +}); diff --git a/tests/integration/module.regressions.js b/tests/integration/module.regressions.js deleted file mode 100644 index f778760..0000000 --- a/tests/integration/module.regressions.js +++ /dev/null @@ -1,27 +0,0 @@ -describe("Space.Module - regressions", function() { - - it("ensures autoboot singletons have access to injector mappings made in module onInitialize", function() { - - Test = Space.namespace('Test'); - SomeLib = { libMethod: function() {} }; - let singletonReadySpy = sinon.spy(); - let myInjector = new Space.Injector(); - - Space.Object.extend(Test, 'MySingleton', { - dependencies: { someLib: 'SomeLib' }, - onDependenciesReady: singletonReadySpy - }); - - Test.MyModule = Space.Module.extend(Test, 'MyModule', { - singletons: ['Test.MySingleton'], - onInitialize() { this.injector.map('SomeLib').to(SomeLib); } - }); - - let module = new Test.MyModule(); - module.initialize(module, myInjector); - - expect(singletonReadySpy).to.have.been.called; - - }); - -}); diff --git a/tests/integration/standalone_application.integration.coffee b/tests/integration/standalone_application.integration.coffee deleted file mode 100644 index 4a78345..0000000 --- a/tests/integration/standalone_application.integration.coffee +++ /dev/null @@ -1,107 +0,0 @@ - -describe 'Meteor integration in applications', -> - - it 'maps Meteor core packages into the Space environment', -> - - class SharedApp extends Space.Application - - dependencies: - meteor: 'Meteor' - ejson: 'EJSON' - ddp: 'DDP' - accounts: 'Accounts' - random: 'Random' - underscore: 'underscore' - reactiveVar: 'ReactiveVar' - mongo: 'Mongo' - - onInitialize: -> - expect(@meteor).to.be.defined - expect(@meteor).to.equal Meteor - - expect(@ejson).to.be.defined - expect(@ejson).to.equal EJSON - - expect(@ddp).to.be.defined - expect(@ddp).to.equal DDP - - expect(@accounts).to.be.defined - expect(@accounts).to.equal Package['accounts-base'].Accounts - - expect(@random).to.be.defined - expect(@random).to.equal Random - - expect(@underscore).to.be.defined - expect(@underscore).to.equal Package.underscore._ - - expect(@reactiveVar).to.equal Package['reactive-var'].ReactiveVar - - expect(@mongo).to.be.defined - expect(@mongo).to.equal Mongo - - new SharedApp() - - # CLIENT ONLY - - if Meteor.isClient - - class ClientApp extends Space.Application - - dependencies: - tracker: 'Tracker' - templates: 'Template' - session: 'Session' - blaze: 'Blaze' - - onInitialize: -> - - expect(@tracker).to.be.defined - expect(@tracker).to.equal Tracker - - expect(@templates).to.be.defined - expect(@templates).to.equal Template - - expect(@session).to.be.defined - expect(@session).to.equal Session - - expect(@blaze).to.be.defined - expect(@blaze).to.equal Blaze - - new ClientApp() - - # SERVER ONLY - - if Meteor.isServer - - class ServerApp extends Space.Application - - dependencies: - email: 'Email' - process: 'process' - Future: 'Future' - mongoInternals: 'MongoInternals' - - onInitialize: -> - expect(@email).to.be.defined - expect(@email).to.equal Package['email'].Email - expect(@process).to.be.defined - expect(@process).to.equal process - expect(@Future).to.be.defined - expect(@Future).to.equal Npm.require 'fibers/future' - expect(@mongoInternals).to.be.defined - expect(@mongoInternals).to.equal MongoInternals - - new ServerApp() - - it 'boots core Space Services', -> - - class SharedApp extends Space.Application - - dependencies: - log: 'log' - - onInitialize: -> - expect(@log).to.be.defined - expect(@log).to.be.instanceOf Space.Logger - - new SharedApp() diff --git a/tests/unit/application.unit.coffee b/tests/unit/application.unit.coffee deleted file mode 100644 index df69d1a..0000000 --- a/tests/unit/application.unit.coffee +++ /dev/null @@ -1,104 +0,0 @@ - -describe 'Space.Application', -> - - beforeEach -> - # Reset published space modules - Space.Module.published = {} - - it 'extends Space.Module', -> - expect(Space.Application).to.extend Space.Module - - describe 'construction', -> - - it 'initializes modules map as empty object', -> - expect(new Space.Application().modules).to.eql {} - - it 'creates a new injector instance if none was given', -> - expect(new Space.Application().injector).to.be.instanceof Space.Injector - - it 'uses the provided injector when given', -> - injector = new Space.Injector() - application = new Space.Application injector: injector - expect(application.injector).to.equal injector - - it 'can also be created via static create method', -> - injector = new Space.Injector() - application = Space.Application.create injector: injector - expect(application.injector).to.equal injector - expect(Space.Application.create().injector).to.be.instanceof Space.Injector - - it 'maps injector instance with itself', -> - injector = new Space.Injector() - injectionMapping = - to: sinon.spy() - toInstancesOf: sinon.spy() - injector.map = sinon.stub().returns injectionMapping - application = new Space.Application injector: injector - - expect(injector.map).to.have.been.calledWithExactly 'Injector' - expect(injectionMapping.to).to.have.been.calledWithExactly injector - - it 'initializes the application', -> - initializeSpy = sinon.spy Space.Application.prototype, 'initialize' - application = new Space.Application() - expect(initializeSpy).to.have.been.calledOnce - initializeSpy.restore() - - it 'can be passed a configuration', -> - - @application = new Space.Application({ - configuration: { - environment: 'testing' - } - }) - expect(@application.configuration.environment).to.equal('testing') - - it 'merges configurations of all modules and user options', -> - class GrandchildModule extends Space.Module - @publish this, 'GrandchildModule' - configuration: { - subModuleValue: 'grandChild' - grandchild: { - toChange: 'grandchildChangeMe' - toKeep: 'grandchildKeepMe' - } - } - - class ChildModule extends Space.Module - @publish this, 'ChildModule' - requiredModules: ['GrandchildModule'] - configuration: { - subModuleValue: 'child' - child: { - toChange: 'childChangeMe' - toKeep: 'childKeepMe' - } - } - class TestApp extends Space.Application - requiredModules: ['ChildModule'] - configuration: { - toChange: 'appChangeMe' - subModuleValue: 'overriddenByApp' - } - app = new TestApp() - app.configure { - toChange: 'appNewValue' - child: { - toChange: 'childNewValue' - } - grandchild: { - toChange: 'grandchildNewValue' - } - } - expect(app.injector.get 'configuration').toMatch { - toChange: 'appNewValue' - subModuleValue: 'overriddenByApp' - child: { - toChange: 'childNewValue' - toKeep: 'childKeepMe' - } - grandchild: { - toChange: 'grandchildNewValue' - toKeep: 'grandchildKeepMe' - } - } diff --git a/tests/unit/helpers.unit.coffee b/tests/unit/helpers.unit.coffee deleted file mode 100644 index ded7bf4..0000000 --- a/tests/unit/helpers.unit.coffee +++ /dev/null @@ -1,10 +0,0 @@ - -global = this - -describe 'Space.resolvePath', -> - - it 'returns a deeply nested object', -> - expect(Space.resolvePath 'Space.Application').to.equal Space.Application - - it 'returns the global context if path is empty', -> - expect(Space.resolvePath '').to.equal global diff --git a/tests/unit/injector.unit.coffee b/tests/unit/injector.unit.coffee deleted file mode 100644 index fba4adb..0000000 --- a/tests/unit/injector.unit.coffee +++ /dev/null @@ -1,265 +0,0 @@ - -Injector = Space.Injector -global = this - -describe 'Space.Injector', -> - - beforeEach -> @injector = new Injector() - - # ============ MAPPINGS ============ # - - describe 'working with mappings', -> - - it 'injects into requested dependency', -> - myObject = dependencies: test: 'test' - testValue = {} - @injector.map('test').to testValue - @injector.map('myObject').to myObject - - expect(@injector.get('myObject').test).to.equal testValue - - it 'throws error if mapping doesnt exist', -> - id = 'blablub' - expect(=> @injector.get(id)).to.throw( - @injector.ERRORS.noMappingFound(id).message - ) - - it 'throws error if mapping would be overriden', -> - @injector.map('test').to 'test' - override = => @injector.map('test').to 'other' - expect(override).to.throw Error - - it 'can remove existing mappings', -> - @injector.map('test').to 'test' - @injector.remove 'test' - expect(=> @injector.get 'test').to.throw - - it 'provides an alias for getting values', -> - @injector.map('test').to 'test' - expect(@injector.create 'test').to.equal 'test' - - it 'uses the toString method if its not a string id', -> - class TestClass extends Space.Object - @toString: -> 'TestClass' - - @injector.map(TestClass).asSingleton() - expect(@injector.get('TestClass')).to.be.instanceof TestClass - - it 'throws error if you try to map undefined', -> - expect(=> @injector.map(undefined)).to.throw @injector.ERRORS.cannotMapUndefinedId() - expect(=> @injector.map(null)).to.throw @injector.ERRORS.cannotMapUndefinedId() - - describe 'overriding mappings', -> - - it 'allows to override mappings', -> - @injector.map('test').to 'test' - @injector.override('test').to 'other' - expect(@injector.get('test')).to.equal 'other' - - it 'dynamically updates all dependent objects with the new dependency', -> - myObject = dependencies: test: 'test' - firstValue = { first: true } - secondValue = { second: true } - @injector.map('test').to firstValue - @injector.injectInto myObject - expect(myObject.test).to.equal firstValue - @injector.override('test').to secondValue - expect(myObject.test).to.equal secondValue - - it 'allows to de-register a dependent object from the mappings', -> - myObject = { - dependencies: - first: 'First' - second: 'Second' - } - firstValue = { first: true } - secondValue = { second: true } - @injector.map('First').to firstValue - @injector.map('Second').to secondValue - - @injector.injectInto myObject - firstMapping = @injector.getMappingFor 'First' - secondMapping = @injector.getMappingFor 'Second' - expect(firstMapping.hasDependent(myObject)).to.be.true - expect(secondMapping.hasDependent(myObject)).to.be.true - # Release the reference to the dependent - @injector.release(myObject) - expect(firstMapping.hasDependent(myObject)).to.be.false - expect(secondMapping.hasDependent(myObject)).to.be.false - - it 'tells the dependent object when a dependency changed', -> - dependentObject = { - dependencies: { - test: 'Test' - } - onDependencyChanged: sinon.spy() - } - firstValue = {} - secondValue = {} - @injector.map('Test').to firstValue - @injector.injectInto dependentObject - @injector.override('Test').to secondValue - - expect(dependentObject.onDependencyChanged).to.have.been.calledWith( - 'test', secondValue - ) - - # ========== INJECTING DEPENDENCIES ========= # - - describe 'injecting dependencies', -> - - it 'injects static values', -> - value = {} - @injector.map('test').to value - instance = Space.Object.create dependencies: value: 'test' - @injector.injectInto instance - expect(instance.value).to.equal value - - it 'injects into provided dependencies', -> - first = dependencies: value: 'test' - second = dependencies: first: 'first' - @injector.map('test').to 'value' - @injector.map('first').to first - - @injector.injectInto second - expect(second.first).to.equal first - expect(first.value).to.equal 'value' - - it 'handles inherited dependencies', -> - Base = Space.Object.extend dependencies: base: 'base' - Extended = Base.extend dependencies: extended: 'extended' - @injector.map('base').to 'base' - @injector.map('extended').to 'extended' - - instance = new Extended() - @injector.injectInto instance - expect(instance.base).to.equal 'base' - expect(instance.extended).to.equal 'extended' - - it 'never overrides existing properties', -> - instance = Space.Object.create - dependencies: test: 'test' - test: 'value' - - @injector.map('test').to('test') - @injector.injectInto instance - - expect(instance.test).to.equal 'value' - - describe 'when dependencies are ready', -> - - it 'tells the instance that they are ready', -> - value = 'test' - instance = Space.Object.create - dependencies: value: 'value' - onDependenciesReady: sinon.spy() - - @injector.map('value').to('value') - @injector.injectInto instance - @injector.injectInto instance # shouldnt trigger twice - - expect(instance.onDependenciesReady).to.have.been.calledOnce - - it 'tells every single instance exactly once', -> - readySpy = sinon.spy() - class TestClass extends Space.Object - dependencies: value: 'test' - onDependenciesReady: readySpy - - @injector.map('test').to 'test' - @injector.map('TestClass').toInstancesOf TestClass - - first = @injector.create 'TestClass' - second = @injector.create 'TestClass' - - expect(readySpy).to.have.been.calledTwice - expect(readySpy).to.have.been.calledOn first - expect(readySpy).to.have.been.calledOn second - - # ============ DEFAULT PROVIDERS ============ # - - describe 'default providers', -> - - describe 'static value providers', -> - - it 'maps to static value', -> - value = 'test' - @injector.map('first').to value - @injector.map('second').toStaticValue value - - expect(@injector.get('first')).to.equal value - expect(@injector.get('second')).to.equal value - - it 'supports global namespace lookup', -> - global.Space.__test__ = TestClass: Space.Object.extend() - path = 'Space.__test__.TestClass' - @injector.map(path).asStaticValue() - - expect(@injector.get(path)).to.equal Space.__test__.TestClass - delete global.Space.__test__ - - it 'can uses static toString method if available', -> - class Test - @toString: -> 'Test' - - @injector.map(Test).asStaticValue() - expect(@injector.get('Test')).to.equal Test - - describe 'instance provider', -> - - it 'creates new instances for each request', -> - class Test - @injector.map('Test').toClass Test - - first = @injector.get 'Test' - second = @injector.get 'Test' - - expect(first).to.be.instanceof Test - expect(second).to.be.instanceof Test - expect(first).not.to.equal second - - describe 'singleton provider', -> - - it 'maps class as singleton', -> - class Test - @toString: -> 'Test' - @injector.map(Test).asSingleton() - first = @injector.get('Test') - second = @injector.get('Test') - - expect(first).to.be.instanceof Test - expect(first).to.equal second - - it 'maps id to singleton of class', -> - class Test - @injector.map('Test').toSingleton Test - first = @injector.get('Test') - second = @injector.get('Test') - - expect(first).to.be.instanceof Test - expect(first).to.equal second - - it 'looks up the value on global namespace if only a path is given', -> - global.Space.__test__ = TestClass: Space.Object.extend() - @injector.map('Space.__test__.TestClass').asSingleton() - - first = @injector.get('Space.__test__.TestClass') - second = @injector.get('Space.__test__.TestClass') - - expect(first).to.be.instanceof Space.__test__.TestClass - expect(first).to.equal second - delete global.Space.__test__ - - # ============ CUSTOM PROVIDERS ============ # - - describe 'adding custom providers', -> - - it 'adds the provider to the api', -> - - loremIpsum = 'lorem ipsum' - - @injector.addProvider 'toLoremIpsum', Space.Object.extend - Constructor: -> @provide = -> loremIpsum - - @injector.map('test').toLoremIpsum() - expect(@injector.get 'test').to.equal loremIpsum diff --git a/tests/unit/logger.tests.js b/tests/unit/logger.tests.js deleted file mode 100644 index acab9e7..0000000 --- a/tests/unit/logger.tests.js +++ /dev/null @@ -1,112 +0,0 @@ -describe("Space.Logger", function() { - - beforeEach(function() { - this.log = new Space.Logger(); - }); - - afterEach(function() { - this.log.stop(); - }); - - it('extends Space.Object', function() { - expect(Space.Logger).to.extend(Space.Object); - }); - - it("is available of both client and server", function() { - if (Meteor.isServer || Meteor.isClient) - expect(this.log).to.be.instanceOf(Space.Logger); - }); - - it("only logs after starting", function() { - this.log.start(); - this.log._logger.info = sinon.spy(); - let message = 'My Log Message'; - this.log.info(message); - expect(this.log._logger.info).to.be.calledWithExactly(message); - }); - - it("it can log a debug message to the output channel when min level is equal but not less", function() { - this.log.start(); - this.log.setMinLevel('debug'); - this.log._logger.debug = sinon.spy(); - let message = 'My log message'; - this.log.debug(message); - expect(this.log._logger.debug).to.be.calledWithExactly(message); - this.log._logger.debug = sinon.spy(); - this.log.setMinLevel('info'); - this.log.debug(message); - expect(this.log._logger.debug).not.to.be.called; - }); - - it("it can log an info message to the output channel when min level is equal or higher, but not less", function() { - this.log.start(); - this.log.setMinLevel('info'); - this.log._logger.info = sinon.spy(); - this.log._logger.debug = sinon.spy(); - let message = 'My log message'; - this.log.info(message); - expect(this.log._logger.info).to.be.calledWithExactly(message); - expect(this.log._logger.debug).not.to.be.called; - this.log._logger.info = sinon.spy(); - this.log.setMinLevel('warning'); - this.log.info(message); - expect(this.log._logger.info).not.to.be.called; - }); - - it.server("it can log a warning message to the output channel when min level is equal or higher, but not less", function() { - this.log.start(); - this.log.setMinLevel('warning'); - this.log._logger.warning = sinon.spy(); - this.log._logger.info = sinon.spy(); - let message = 'My log message'; - this.log.warning(message); - expect(this.log._logger.warning).to.be.calledWithExactly(message); - expect(this.log._logger.info).not.to.be.called; - this.log._logger.warning = sinon.spy(); - this.log.setMinLevel('error'); - this.log.warning(message); - expect(this.log._logger.warning).not.to.be.called; - }); - - it.client("it can log a warning message to the output channel when min level is equal or higher, but not less", function() { - this.log.start(); - this.log.setMinLevel('warning'); - this.log._logger.warn = sinon.spy(); - this.log._logger.info = sinon.spy(); - let message = 'My log message'; - this.log.warning(message); - expect(this.log._logger.warn).to.be.calledWithExactly(message); - expect(this.log._logger.info).not.to.be.called; - this.log._logger.warn = sinon.spy(); - this.log.setMinLevel('error'); - this.log.warning(message); - expect(this.log._logger.warn).not.to.be.called; - }); - - it("it can log an error message to the output channel when min level is equal", function() { - this.log.start(); - this.log.setMinLevel('error'); - this.log._logger.error = sinon.spy(); - this.log._logger.info = sinon.spy(); - let message = 'My log message'; - this.log.error(message); - expect(this.log._logger.error).to.be.calledWithExactly(message); - expect(this.log._logger.info).not.to.be.called; - this.log._logger.info = sinon.spy(); - this.log.setMinLevel('debug'); - this.log.error(message); - expect(this.log._logger.error).to.be.calledWithExactly(message); - }); - - it("allows logging output to be stopped", function() { - this.log._logger.info = sinon.spy(); - this.log.start(); - expect(this.log._is('running')).to.be.true; - this.log.stop(); - let message = 'My Log Message'; - this.log.info(message); - expect(this.log._logger.info).not.to.be.called; - expect(this.log._is('stopped')).to.be.true; - }); - -}); diff --git a/tests/unit/module.unit.coffee b/tests/unit/module.unit.coffee deleted file mode 100644 index 859ad5f..0000000 --- a/tests/unit/module.unit.coffee +++ /dev/null @@ -1,163 +0,0 @@ - -describe 'Space.Module', -> - - beforeEach -> - # Reset published space modules - Space.Module.published = {} - - it 'extends space object', -> expect(Space.Module).to.extend Space.Object - - describe '@publish', -> - - it 'adds given module to the static collection of published modules', -> - module = Space.Module.define 'test' - expect(Space.Module.published['test']).to.equal module - - it 'throws an error if two modules try to publish under same name', -> - publishTwoModulesWithSameName = -> - Space.Module.define 'test' - Space.Module.define 'test' - expect(publishTwoModulesWithSameName).to.throw Error - - describe '@require', -> - - it 'returns published module for given identifier', -> - module = Space.Module.define 'test' - requiredModule = Space.Module.require 'test' - expect(requiredModule).to.equal module - - it 'throws and error if no module was registered for given identifier', -> - requireUnkownModule = -> Space.Module.require 'unknown module' - expect(requireUnkownModule).to.throw Error - - describe 'constructor', -> - - it 'sets required modules to empty array if none defined', -> - module = new Space.Module() - expect(module.requiredModules).to.be.instanceof Array - expect(module.requiredModules).to.be.empty - - it 'leaves the defined required modules intact', -> - testArray = [] - module = Space.Module.create requiredModules: testArray - expect(module.requiredModules).to.equal testArray - - it 'sets the correct state', -> - module = new Space.Module() - expect(module.is 'constructed').to.be.true - - -describe 'Space.Module - #initialize', -> - - beforeEach -> - # Reset published space modules - Space.Module.published = {} - @injector = new Space.Injector() - sinon.spy @injector, 'injectInto' - @module = new Space.Module() - # faked required modules to spy on - @SubModule1 = Space.Module.define 'SubModule1' - @SubModule2 = Space.Module.define 'SubModule2' - @app = modules: {} - - it 'asks the injector to inject dependencies into the module', -> - @module.initialize @app, @injector - expect(@injector.injectInto).to.have.been.calledWith @module - - it 'throws an error if no injector is provided', -> - initializeWithoutInjector = => @module.initialize() - expect(initializeWithoutInjector).to.throw Error - - it 'sets the initialized flag correctly', -> - @module.initialize @app, @injector - expect(@module.is 'initialized').to.be.true - - it.server 'adds Npm as property to the module', -> - @module.initialize @app, @injector - expect(@module.npm.require).to.be.defined - - it 'invokes the onInitialize method on itself', -> - @module.onInitialize = sinon.spy() - @module.initialize @app, @injector - expect(@module.onInitialize).to.have.been.calledOnce - - it 'creates required modules and adds them to the app', -> - @module.requiredModules = [@SubModule1.name, @SubModule2.name] - @module.initialize @app, @injector - expect(@app.modules[@SubModule1.name]).to.be.instanceof(@SubModule1) - expect(@app.modules[@SubModule2.name]).to.be.instanceof(@SubModule2) - - it 'initializes required modules', -> - sinon.stub @SubModule1.prototype, 'initialize' - @module.requiredModules = [@SubModule1.name] - @module.initialize @app, @injector - expect(@SubModule1.prototype.initialize).to.have.been.calledOnce - - it 'can only be initialized once', -> - @module.onInitialize = sinon.spy() - @module.initialize @app, @injector - @module.initialize @app, @injector - expect(@module.onInitialize).to.have.been.calledOnce - -describe 'Space.Module - #start', -> - - beforeEach -> - @module = new Space.Module() - @module.start() - @module._runLifeCycleAction = sinon.spy() - - it 'sets the state to running', -> - expect(@module.is 'running').to.be.true - - it 'ignores start calls on a running module', -> - @module.start() - expect(@module._runLifeCycleAction).not.to.have.been.called - -describe 'Space.Module - #stop', -> - - beforeEach -> - @module = new Space.Module() - @module.start() - @module.stop() - @module._runLifeCycleAction = sinon.spy() - - it 'sets the state to stopped', -> - expect(@module.is 'stopped').to.be.true - - it 'ignores stop calls on a stopped module', -> - @module.stop() - expect(@module._runLifeCycleAction).not.to.have.been.called - -describe 'Space.Module - #reset', -> - - beforeEach -> - @module = new Space.Module() - @module._runLifeCycleAction = sinon.spy() - - it.server 'rejects attempts to reset when in production', -> - nodeEnvBackup = process.env.NODE_ENV - process.env.NODE_ENV = 'production' - @module.reset() - process.env.NODE_ENV = nodeEnvBackup - expect(@module._runLifeCycleAction).not.to.have.been.called - -describe "Space.Module - wrappable lifecycle hooks", -> - - it "allows mixins to hook into the module lifecycle", -> - moduleOnInitializeSpy = sinon.spy() - mixinOnInitializeSpy = sinon.spy() - MyModule = Space.Module.extend { - onInitialize: moduleOnInitializeSpy - } - MyModule.mixin { - onDependenciesReady: -> - @_wrapLifecycleHook 'onInitialize', (onInitialize) -> - onInitialize.call(this) - mixinOnInitializeSpy.call(this) - } - module = new MyModule() - module.initialize(module, new Space.Injector()) - - expect(moduleOnInitializeSpy).to.have.been.calledOnce - expect(mixinOnInitializeSpy).to.have.been.calledOnce - expect(moduleOnInitializeSpy).to.have.been.calledBefore(mixinOnInitializeSpy) diff --git a/tests/unit/object.unit.coffee b/tests/unit/object.unit.coffee deleted file mode 100644 index 97211e9..0000000 --- a/tests/unit/object.unit.coffee +++ /dev/null @@ -1,255 +0,0 @@ - -describe 'Space.Object', -> - - beforeEach -> @namespace = {} - - describe 'extending', -> - - it 'creates and returns a subclass', -> - Space.Object.extend(@namespace, 'MyClass') - expect(@namespace.MyClass).to.extend Space.Object - - it 'applies the arguments to the super constructor', -> - [first, second, third] = ['first', 2, {}] - spy = sinon.spy() - Space.Object.extend @namespace, 'Base', { - Constructor: -> spy.apply this, arguments - } - @namespace.Base.extend(@namespace, 'Extended') - instance = new @namespace.Extended first, second, third - expect(spy).to.have.been.calledWithExactly first, second, third - expect(spy).to.have.been.calledOn instance - - it 'allows to extend the prototype', -> - First = Space.Object.extend first: 1, get: (property) -> @[property] - Second = First.extend second: 2, get: -> First::get.apply this, arguments - class Third extends Second - get: (property) -> super property - instance = new Third() - expect(instance.get('first')).to.equal 1 - expect(instance.get('second')).to.equal 2 - - describe "providing fully qualified class path", -> - - it "registers the class for internal lookup", -> - Space.namespace('My.custom') - FirstClass = Space.Object.extend('My.custom.FirstClass', {}) - SecondClass = Space.Object.extend('My.custom.SecondClass', {}) - expect(Space.resolvePath 'My.custom.FirstClass').to.equal(FirstClass) - expect(Space.resolvePath 'My.custom.SecondClass').to.equal(SecondClass) - - it "assigns the class path", -> - className = 'My.custom.Class' - MyClass = Space.Object.extend(className) - expect(MyClass.toString()).to.equal(className) - expect(new MyClass().toString()).to.equal(className) - - it "exposes the class on the global scope if possible", -> - my = {} - my.namespace = Space.namespace('my.namespace') - MyClass = Space.Object.extend('my.namespace.MyClass') - expect(my.namespace.MyClass).to.equal(MyClass) - - it "works correctly without nested namespaces", -> - MyClass = Space.Object.extend('MyClass') - expect(Space.resolvePath 'MyClass').to.equal(MyClass) - - describe "working with static class properties", -> - - it 'allows you to define static class properties', -> - myStatics = {} - MyClass = Space.Object.extend statics: { myStatics: myStatics } - expect(MyClass.myStatics).to.equal(myStatics) - - it 'provides an api for defining a callback while extending', -> - onExtendingSpy = sinon.spy() - MyClass = Space.Object.extend onExtending: onExtendingSpy - expect(onExtendingSpy).to.have.been.calledOn(MyClass) - - describe 'creating instances', -> - - it 'creates a new instance of given class', -> - expect(Space.Object.create()).to.be.instanceof Space.Object - - it 'allows to initialize the instance with given properties', -> - instance = Space.Object.create first: 1, get: (property) -> @[property] - expect(instance.get 'first').to.equal 1 - - it 'forwards any number of arguments to the constructor', -> - Base = Space.Object.extend Constructor: (@first, @second) -> - instance = Base.create 1, 2 - expect(instance.first).to.equal 1 - expect(instance.second).to.equal 2 - - describe "inheritance helpers", -> - - Base = Space.Object.extend { - statics: { prop: 'static', method: -> } - prop: 'prototype' - method: -> - } - Sub = Base.extend() - GrandSub = Sub.extend() - - describe "static", -> - - it "can tell if there is a super class", -> - expect(Sub.hasSuperClass()).to.be.true - - it "can return the super class", -> - expect(Sub.superClass()).to.equal(Base) - - it "returns undefined if there is no super class", -> - expect(Space.Object.superClass()).to.equal(undefined) - - it "can return a static prop or method of the super class", -> - expect(Sub.superClass('prop')).to.equal(Base.prop) - expect(Sub.superClass('method')).to.equal(Base.method) - - it "can give back a flat array of sub classes", -> - expect(Base.subClasses()).to.eql [Sub, GrandSub] - expect(Sub.subClasses()).to.eql [GrandSub] - expect(GrandSub.subClasses()).to.eql [] - - describe "prototype", -> - - it "can tell if there is a super class", -> - expect(new Sub().hasSuperClass()).to.be.true - - it "can return the super class", -> - expect(new Sub().superClass()).to.equal(Base) - - it "can return a static prop or method of the super class", -> - expect(new Sub().superClass('prop')).to.equal(Base::prop) - expect(new Sub().superClass('method')).to.equal(Base::method) - - describe 'mixins', -> - - it 'adds methods to the prototype', -> - testMixin = test: -> - TestClass = Space.Object.extend() - TestClass.mixin testMixin - expect(TestClass::test).to.equal testMixin.test - - it 'overrides existing methods of the prototype', -> - testMixin = test: -> - TestClass = Space.Object.extend test: -> - TestClass.mixin testMixin - expect(TestClass::test).to.equal testMixin.test - - it 'merges object properties', -> - testMixin = dependencies: second: 'second' - TestClass = Space.Object.extend dependencies: first: 'first' - TestClass.mixin testMixin - expect(TestClass::dependencies.first).to.equal 'first' - expect(TestClass::dependencies.second).to.equal 'second' - - it "does not modify other mixins when merging properties", -> - FirstMixin = dependencies: firstMixin: 'onExtending' - FirstClass = Space.Object.extend { - mixin: [FirstMixin] - dependencies: first: 'first' - } - FirstClass.mixin dependencies: firstMixin: 'afterExtending' - expect(FirstMixin).toMatch dependencies: firstMixin: 'onExtending' - expect(FirstClass.prototype.dependencies).toMatch { - first: 'first' - firstMixin: 'afterExtending' - } - - it "can provide a hook that is called when the mixin is applied", -> - myMixin = onMixinApplied: sinon.spy() - TestClass = Space.Object.extend() - TestClass.mixin myMixin - expect(myMixin.onMixinApplied).to.have.been.calledOnce - - it 'can be defined as prototype property when extending classes', -> - myMixin = { onMixinApplied: sinon.spy() } - MyClass = Space.Object.extend mixin: [myMixin] - expect(myMixin.onMixinApplied).to.have.been.calledOn(MyClass) - - it 'can be used to mixin static properties on to the class', -> - myMixin = statics: { myMethod: sinon.spy() } - MyClass = Space.Object.extend mixin: [myMixin] - MyClass.myMethod() - expect(myMixin.statics.myMethod).to.have.been.calledOn(MyClass) - - it 'can be checked which mixins a class has', -> - FirstMixin = {} - SecondMixin = {} - ThirdMixin = {} - MyClass = Space.Object.extend({ mixin: FirstMixin }) - MyClass.mixin(SecondMixin) - instance = new MyClass() - # Static checks - expect(MyClass.hasMixin(FirstMixin)).to.be.true - expect(MyClass.hasMixin(SecondMixin)).to.be.true - expect(MyClass.hasMixin(ThirdMixin)).to.be.false - # Instance checks - expect(instance.hasMixin(FirstMixin)).to.be.true - expect(instance.hasMixin(SecondMixin)).to.be.true - expect(instance.hasMixin(ThirdMixin)).to.be.false - - describe "mixin inheritance", -> - - it "does not apply mixins to super classes", -> - firstMixin = {} - secondMixin = {} - SuperClass = Space.Object.extend mixin: firstMixin - SubClass = SuperClass.extend mixin: secondMixin - expect(SuperClass.hasMixin(firstMixin)).to.be.true - expect(SuperClass.hasMixin(secondMixin)).to.be.false - expect(SubClass.hasMixin(firstMixin)).to.be.true - expect(SubClass.hasMixin(secondMixin)).to.be.true - - it "inherits mixins to children when added to base class later on", -> - LateMixin = { statics: { test: 'property' } } - # Base class with a mixin - BaseClass = Space.Object.extend() - # Sublcass with its own mixin - SubClass = BaseClass.extend() - # Later we extend base class - BaseClass.mixin LateMixin - # Sub class should have all three mixins correctly applied - expect(SubClass.hasMixin(LateMixin)).to.be.true - expect(SubClass.test).to.equal LateMixin.statics.test - - describe "onDependenciesReady hooks", -> - - it "can provide a hook that is called when dependencies of host class are ready", -> - myMixin = onDependenciesReady: sinon.spy() - TestClass = Space.Object.extend() - TestClass.mixin myMixin - new TestClass().onDependenciesReady() - expect(myMixin.onDependenciesReady).to.have.been.calledOnce - - it "inherits the onDependenciesReady hooks to sub classes", -> - firstMixin = onDependenciesReady: sinon.spy() - secondMixin = onDependenciesReady: sinon.spy() - SuperClass = Space.Object.extend() - SuperClass.mixin firstMixin - SubClass = SuperClass.extend() - SubClass.mixin secondMixin - new SubClass().onDependenciesReady() - expect(firstMixin.onDependenciesReady).to.have.been.calledOnce - expect(secondMixin.onDependenciesReady).to.have.been.calledOnce - - it "calls inherited mixin hooks only once per chain", -> - myMixin = onDependenciesReady: sinon.spy() - SuperClass = Space.Object.extend() - SuperClass.mixin myMixin - SubClass = SuperClass.extend() - new SubClass().onDependenciesReady() - expect(myMixin.onDependenciesReady).to.have.been.calledOnce - - describe "construction hooks", -> - - it "can provide a hook that is called on construction of host class", -> - myMixin = onConstruction: sinon.spy() - TestClass = Space.Object.extend() - TestClass.mixin myMixin - first = {} - second = {} - new TestClass(first, second) - expect(myMixin.onConstruction).to.have.been.calledWithExactly(first, second) - diff --git a/tests/unit/struct.unit.coffee b/tests/unit/struct.unit.coffee deleted file mode 100644 index be96747..0000000 --- a/tests/unit/struct.unit.coffee +++ /dev/null @@ -1,64 +0,0 @@ - -describe 'Space.Struct', -> - - class MyTestStruct extends Space.Struct - @type 'MyTestStruct' - fields: -> name: String, age: Match.Integer - - class MyExtendedTestStruct extends MyTestStruct - @type 'MyExtendedTestStruct' - fields: -> - fields = super() - fields.extra = Match.Integer - return fields - - it "is a Space.Object", -> - expect(Space.Struct).to.extend(Space.Object) - - it "calls the super constructor", -> - constructorSpy = sinon.spy(Space.Object.prototype, 'constructor') - data = {} - struct = new Space.Struct(data) - expect(constructorSpy).to.have.been.calledWithExactly(data) - expect(constructorSpy).to.have.been.calledOn(struct) - constructorSpy.restore() - - describe 'defining fields', -> - - it 'assigns the properties to the instance', -> - properties = name: 'Dominik', age: 26 - instance = new MyTestStruct properties - expect(instance).toMatch properties - - it 'provides a method to cast to plain object', -> - instance = new MyTestStruct name: 'Dominik', age: 26 - copy = instance.toPlainObject() - expect(copy.name).to.equal 'Dominik' - expect(copy.age).to.equal 26 - expect(copy).to.be.an.object - expect(copy).not.to.be.instanceof MyTestStruct - - it 'throws a match error if a property is of wrong type', -> - expect(-> new MyTestStruct name: 5, age: 26).to.throw Match.Error - - it 'throws a match error if additional properties are given', -> - expect(-> new MyTestStruct name: 5, age: 26, extra: 0).to.throw Match.Error - - it 'throws a match error if a property is missing', -> - expect(-> new MyTestStruct name: 5).to.throw Match.Error - - it 'allows to extend the fields of base classes', -> - expect(-> new MyExtendedTestStruct name: 'test', age: 26, extra: 0) - .not.to.throw Match.Error - - # TODO: remove when breaking change is made for next major version: - it 'stays backward compatible with static fields api', -> - class StaticFieldsStruct extends Space.Struct - @fields: { name: String, age: Match.Integer } - - properties = name: 'Dominik', age: 26 - instance = new StaticFieldsStruct properties - expect(instance).toMatch properties - expect(-> new StaticFieldsStruct name: 5).to.throw Match.Error - -