// Copyright IBM Corp. 2014,2016. All Rights Reserved.
// Node module: loopback-boot
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

'use strict';

var boot = require('../');
var fs = require('fs-extra');
var path = require('path');
var expect = require('chai').expect;
var sandbox = require('./helpers/sandbox');
var appdir = require('./helpers/appdir');

// add coffee-script to require.extensions
require('coffeescript/register');

var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app');

describe('compiler', function() {
  beforeEach(sandbox.reset);
  beforeEach(appdir.init);

  function expectCompileToThrow(err, options, done) {
    if (typeof options === 'function') {
      done = options;
      options = undefined;
    }
    boot.compile(options || appdir.PATH, function(err) {
      expect(function() {
        if (err) throw err;
      }).to.throw(err);
      done();
    });
  }

  function expectCompileToNotThrow(options, done) {
    if (typeof options === 'function') {
      done = options;
      options = undefined;
    }
    boot.compile(options || appdir.PATH, function(err) {
      expect(function() {
        if (err) throw err;
      }).to.not.throw();
      done();
    });
  }

  describe('from options', function() {
    var options, instructions, appConfig;

    beforeEach(function(done) {
      options = {
        application: {
          port: 3000,
          host: '127.0.0.1',
          restApiRoot: '/rest-api',
          foo: {bar: 'bat'},
          baz: true,
        },
        models: {
          'foo-bar-bat-baz': {
            dataSource: 'the-db',
          },
        },
        dataSources: {
          'the-db': {
            connector: 'memory',
            defaultForType: 'db',
          },
        },
      };
      boot.compile(options, function(err, context) {
        if (err) return done(err);
        appConfig = context.instructions.application;
        instructions = context.instructions;
        done();
      });
    });

    it('has port setting', function() {
      expect(appConfig).to.have.property('port', 3000);
    });

    it('has host setting', function() {
      expect(appConfig).to.have.property('host', '127.0.0.1');
    });

    it('has restApiRoot setting', function() {
      expect(appConfig).to.have.property('restApiRoot', '/rest-api');
    });

    it('has other settings', function() {
      expect(appConfig).to.have.property('baz', true);
      expect(appConfig.foo, 'appConfig.foo').to.eql({
        bar: 'bat',
      });
    });

    it('has models definition', function() {
      expect(instructions.models).to.have.length(1);
      expect(instructions.models[0]).to.eql({
        name: 'foo-bar-bat-baz',
        config: {
          dataSource: 'the-db',
        },
        definition: undefined,
        sourceFile: undefined,
      });
    });

    it('has datasources definition', function() {
      expect(instructions.dataSources).to.eql(options.dataSources);
    });

    describe('with custom model definitions', function(done) {
      var dataSources = {
        'the-db': {connector: 'memory'},
      };

      it('loads model without definition', function(done) {
        var instruction = boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'model-without-definition': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models[0].name).to.equal(
              'model-without-definition'
            );
            expect(instructions.models[0].definition).to.equal(undefined);
            expect(instructions.models[0].sourceFile).to.equal(undefined);
            done();
          }
        );
      });

      it('loads coffeescript models', function(done) {
        var modelScript = appdir.writeFileSync(
          'custom-models/coffee-model-with-definition.coffee',
          ''
        );
        boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'coffee-model-with-definition': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [
              {
                definition: {
                  name: 'coffee-model-with-definition',
                },
                sourceFile: modelScript,
              },
            ],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models[0].name).to.equal(
              'coffee-model-with-definition'
            );
            expect(instructions.models[0].definition).to.eql({
              name: 'coffee-model-with-definition',
            });
            expect(instructions.models[0].sourceFile).to.equal(modelScript);
            done();
          }
        );
      });

      it('handles sourceFile path without extension (.js)', function(done) {
        var modelScript = appdir.writeFileSync(
          'custom-models/model-without-ext.coffee',
          ''
        );
        boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'model-without-ext': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [
              {
                definition: {
                  name: 'model-without-ext',
                },
                sourceFile: pathWithoutExtension(modelScript),
              },
            ],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models[0].name).to.equal('model-without-ext');
            expect(instructions.models[0].sourceFile).to.equal(modelScript);
            done();
          }
        );
      });

      it('handles sourceFile path without extension (.coffee)', function(done) {
        var modelScript = appdir.writeFileSync(
          'custom-models/model-without-ext.coffee',
          ''
        );
        boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'model-without-ext': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [
              {
                definition: {
                  name: 'model-without-ext',
                },
                sourceFile: pathWithoutExtension(modelScript),
              },
            ],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models[0].name).to.equal('model-without-ext');
            expect(instructions.models[0].sourceFile).to.equal(modelScript);
            done();
          }
        );
      });

      it('sets source file path if the file exist', function(done) {
        var modelScript = appdir.writeFileSync(
          'custom-models/model-with-definition.js',
          ''
        );
        boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'model-with-definition': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [
              {
                definition: {
                  name: 'model-with-definition',
                },
                sourceFile: modelScript,
              },
            ],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models[0].name).to.equal(
              'model-with-definition'
            );
            expect(instructions.models[0].definition).not.to.equal(undefined);
            expect(instructions.models[0].sourceFile).to.equal(modelScript);
            done();
          }
        );
      });

      it('does not set source file path if the file does not exist.',
        function(done) {
          boot.compile(
            {
              appRootDir: appdir.PATH,
              models: {
                'model-with-definition-with-falsey-source-file': {
                  dataSource: 'the-db',
                },
              },
              modelDefinitions: [
                {
                  definition: {
                    name: 'model-with-definition-with-falsey-source-file',
                  },
                  sourceFile: appdir.resolve(
                    'custom-models',
                    'file-does-not-exist.js'
                  ),
                },
              ],
              dataSources: dataSources,
            },
            function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.models[0].name).to.equal(
                'model-with-definition-with-falsey-source-file'
              );
              expect(instructions.models[0].definition).not.to.equal(undefined);
              expect(instructions.models[0].sourceFile).to.equal(undefined);
              done();
            }
          );
        });

      it('does not set source file path if no source file supplied.',
        function(done) {
          boot.compile(
            {
              appRootDir: appdir.PATH,
              models: {
                'model-with-definition-without-source-file-property': {
                  dataSource: 'the-db',
                },
              },
              modelDefinitions: [
                {
                  definition: {
                    name: 'model-with-definition-without-source-file-property',
                  },
                // sourceFile is not set
                },
              ],
              dataSources: dataSources,
            },
            function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.models[0].name).to.equal(
                'model-with-definition-without-source-file-property'
              );
              expect(instructions.models[0].definition).not.to.equal(undefined);
              expect(instructions.models[0].sourceFile).to.equal(undefined);
              done();
            }
          );
        });

      it('loads models defined in `models` only.', function(done) {
        boot.compile(
          {
            appRootDir: appdir.PATH,
            models: {
              'some-model': {
                dataSource: 'the-db',
              },
            },
            modelDefinitions: [
              {
                definition: {
                  name: 'some-model',
                },
              },
              {
                definition: {
                  name: 'another-model',
                },
              },
            ],
            dataSources: dataSources,
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.models.map(getNameProperty)).to.eql([
              'some-model',
            ]);
            done();
          }
        );
      });
    });
  });

  describe('from directory', function(done) {
    it('loads config files', function(done) {
      boot.compile(SIMPLE_APP, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.eql({
          name: 'User',
          config: {
            dataSource: 'db',
          },
          definition: undefined,
          sourceFile: undefined,
        });
        done();
      });
    });

    it('merges datasource configs from multiple files', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('datasources.local.json', {
        db: {local: 'applied'},
      });

      var env = process.env.NODE_ENV || 'development';
      appdir.writeConfigFileSync('datasources.' + env + '.json', {
        db: {env: 'applied'},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var db = instructions.dataSources.db;
        expect(db).to.have.property('local', 'applied');
        expect(db).to.have.property('env', 'applied');

        var expectedLoadOrder = ['local', 'env'];
        var actualLoadOrder = Object.keys(db).filter(function(k) {
          return expectedLoadOrder.indexOf(k) !== -1;
        });

        expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder);
        done();
      });
    });

    it('supports .js for custom datasource config files', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeFileSync(
        'datasources.local.js',
        'module.exports = { db: { fromJs: true } };'
      );

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var db = instructions.dataSources.db;
        expect(db).to.have.property('fromJs', true);
        done();
      });
    });

    it('merges new Object values', function(done) {
      var objectValue = {key: 'value'};
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('datasources.local.json', {
        db: {nested: objectValue},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var db = instructions.dataSources.db;
        expect(db).to.have.property('nested');
        expect(db.nested).to.eql(objectValue);
        done();
      });
    });

    it('deeply merges Object values', function(done) {
      appdir.createConfigFilesSync(
        {},
        {
          email: {
            transport: {
              host: 'localhost',
            },
          },
        }
      );

      appdir.writeConfigFileSync('datasources.local.json', {
        email: {
          transport: {
            host: 'mail.example.com',
          },
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        var email = instructions.dataSources.email;
        expect(email.transport.host).to.equal('mail.example.com');
        done();
      });
    });

    it('deeply merges Array values of the same length', function(done) {
      appdir.createConfigFilesSync(
        {},
        {
          rest: {
            operations: [
              {
                template: {
                  method: 'POST',
                  url: 'http://localhost:12345',
                },
              },
            ],
          },
        }
      );
      appdir.writeConfigFileSync('datasources.local.json', {
        rest: {
          operations: [
            {
              template: {
                url: 'http://api.example.com',
              },
            },
          ],
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var rest = instructions.dataSources.rest;
        expect(rest.operations[0].template).to.eql({
          method: 'POST', // the value from datasources.json
          url: 'http://api.example.com', // overriden in datasources.local.json
        });
        done();
      });
    });

    it('merges Array properties', function(done) {
      var arrayValue = ['value'];
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('datasources.local.json', {
        db: {nested: arrayValue},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var db = instructions.dataSources.db;
        expect(db).to.have.property('nested');
        expect(db.nested).to.eql(arrayValue);
        done();
      });
    });

    it('does not cache loaded values', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('middleware.json', {
        'strong-error-handler': {params: {debug: false}},
      });
      appdir.writeConfigFileSync('middleware.development.json', {
        'strong-error-handler': {params: {debug: true}},
      });

      // Here we load main config and merge it with DEV overrides
      var bootOptions = {
        appRootDir: appdir.PATH,
        env: 'development',
        phases: ['load'],
      };
      var productionBootOptions = {
        appRootDir: appdir.PATH,
        env: 'production',
        phases: ['load'],
      };
      boot.compile(bootOptions, function(err, context) {
        var config = context.configurations.middleware;
        expect(
          config['strong-error-handler'].params.debug,
          'debug in development'
        ).to.equal(true);

        boot.compile(productionBootOptions, function(err, context2) {
          var config = context2.configurations.middleware;
          expect(
            config['strong-error-handler'].params.debug,
            'debug in production'
          ).to.equal(false);
          done();
        });
      });
    });

    it('allows env specific model-config json', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('model-config.local.json', {
        foo: {dataSource: 'db'},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.have.property('name', 'foo');
        done();
      });
    });

    it('allows env specific model-config json to be merged', function(done) {
      appdir.createConfigFilesSync(null, null, {
        foo: {dataSource: 'mongo', public: false},
      });
      appdir.writeConfigFileSync('model-config.local.json', {
        foo: {dataSource: 'db'},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.have.property('name', 'foo');
        expect(instructions.models[0].config).to.eql({
          dataSource: 'db',
          public: false,
        });
        done();
      });
    });

    it('allows env specific model-config js', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeFileSync(
        'model-config.local.js',
        "module.exports = { foo: { dataSource: 'db' } };"
      );

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.have.property('name', 'foo');
        done();
      });
    });

    it('refuses to merge Array properties of different length', function(done) {
      appdir.createConfigFilesSync({
        nest: {
          array: [],
        },
      });

      appdir.writeConfigFileSync('config.local.json', {
        nest: {
          array: [
            {
              key: 'value',
            },
          ],
        },
      });

      expectCompileToThrow(
        /array values of different length.*nest\.array/,
        done
      );
    });

    it('refuses to merge Array of different length in Array', function(done) {
      appdir.createConfigFilesSync({
        key: [[]],
      });

      appdir.writeConfigFileSync('config.local.json', {
        key: [['value']],
      });

      expectCompileToThrow(/array values of different length.*key\[0\]/, done);
    });

    it('returns full key of an incorrect Array value', function(done) {
      appdir.createConfigFilesSync({
        toplevel: [
          {
            nested: [],
          },
        ],
      });

      appdir.writeConfigFileSync('config.local.json', {
        toplevel: [
          {
            nested: ['value'],
          },
        ],
      });

      expectCompileToThrow(
        /array values of different length.*toplevel\[0\]\.nested/,
        done
      );
    });

    it('refuses to merge incompatible object properties', function(done) {
      appdir.createConfigFilesSync({
        key: [],
      });
      appdir.writeConfigFileSync('config.local.json', {
        key: {},
      });

      expectCompileToThrow(/incompatible types.*key/, done);
    });

    it('refuses to merge incompatible array items', function(done) {
      appdir.createConfigFilesSync({
        key: [[]],
      });
      appdir.writeConfigFileSync('config.local.json', {
        key: [{}],
      });

      expectCompileToThrow(/incompatible types.*key\[0\]/, done);
    });

    it('merges app configs from multiple files', function(done) {
      appdir.createConfigFilesSync();

      appdir.writeConfigFileSync('config.local.json', {cfgLocal: 'applied'});

      var env = process.env.NODE_ENV || 'development';
      appdir.writeConfigFileSync('config.' + env + '.json', {
        cfgEnv: 'applied',
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        var appConfig = instructions.application;

        expect(appConfig).to.have.property('cfgLocal', 'applied');
        expect(appConfig).to.have.property('cfgEnv', 'applied');

        var expectedLoadOrder = ['cfgLocal', 'cfgEnv'];
        var actualLoadOrder = Object.keys(appConfig).filter(function(k) {
          return expectedLoadOrder.indexOf(k) !== -1;
        });

        expect(actualLoadOrder, 'load order').to.eql(expectedLoadOrder);
        done();
      });
    });

    it('supports .js for custom app config files', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeFileSync(
        'config.local.js',
        'module.exports = { fromJs: true };'
      );

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        var appConfig = instructions.application;

        expect(appConfig).to.have.property('fromJs', true);
        done();
      });
    });

    it('supports `appConfigRootDir` option', function(done) {
      appdir.createConfigFilesSync({port: 3000});

      var customDir = path.resolve(appdir.PATH, 'custom');
      fs.mkdirsSync(customDir);
      fs.renameSync(
        path.resolve(appdir.PATH, 'config.json'),
        path.resolve(customDir, 'config.json')
      );

      boot.compile(
        {
          appRootDir: appdir.PATH,
          appConfigRootDir: path.resolve(appdir.PATH, 'custom'),
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.application).to.have.property('port');
          done();
        }
      );
    });

    it('supports `dsRootDir` option', function(done) {
      appdir.createConfigFilesSync();

      var customDir = path.resolve(appdir.PATH, 'custom');
      fs.mkdirsSync(customDir);
      fs.renameSync(
        path.resolve(appdir.PATH, 'datasources.json'),
        path.resolve(customDir, 'datasources.json')
      );

      boot.compile(
        {
          appRootDir: appdir.PATH,
          dsRootDir: path.resolve(appdir.PATH, 'custom'),
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.dataSources).to.have.property('db');
          done();
        }
      );
    });

    it('supports `modelsRootDir` option', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('custom/model-config.json', {
        foo: {dataSource: 'db'},
      });

      boot.compile(
        {
          appRootDir: appdir.PATH,
          modelsRootDir: path.resolve(appdir.PATH, 'custom'),
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0]).to.have.property('name', 'foo');
          done();
        }
      );
    });

    it('includes boot/*.js scripts', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        expect(instructions.bootScripts).to.eql([initJs]);
        done();
      });
    });

    it('supports `bootDirs` option', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'custom-boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootDirs: [path.dirname(initJs)],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('should resolve relative path in `bootDirs`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'custom-boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootDirs: ['./custom-boot'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('should resolve non-relative path in `bootDirs`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync('custom-boot/init.js', '');
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootDirs: ['custom-boot'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('ignores index.js in `bootDirs`', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeFileSync('custom-boot/index.js', '');
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootDirs: ['./custom-boot'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.have.length(0);
          done();
        }
      );
    });

    it('prefers coffeescript over json in `appRootDir/bootDir`',
      function(done) {
        appdir.createConfigFilesSync();
        var coffee = appdir.writeFileSync('./custom-boot/init.coffee', '');
        appdir.writeFileSync('./custom-boot/init.json', {});

        boot.compile(
          {
            appRootDir: appdir.PATH,
            bootDirs: ['./custom-boot'],
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.bootScripts).to.eql([coffee]);
            done();
          }
        );
      });

    it('prefers coffeescript over json in `bootDir` non-relative path',
      function(done) {
        appdir.createConfigFilesSync();
        var coffee = appdir.writeFileSync('custom-boot/init.coffee', '');
        appdir.writeFileSync('custom-boot/init.json', '');

        boot.compile(
          {
            appRootDir: appdir.PATH,
            bootDirs: ['custom-boot'],
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.bootScripts).to.eql([coffee]);
            done();
          }
        );
      });

    it('supports `bootScripts` option', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'custom-boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: [initJs],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('should remove duplicate scripts', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'custom-boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootDirs: [path.dirname(initJs)],
          bootScripts: [initJs],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('should resolve relative path in `bootScripts`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync(
        'custom-boot/init.js',
        'module.exports = function(app) { app.fnCalled = true; };'
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: ['./custom-boot/init.js'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('should resolve non-relative path in `bootScripts`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync('custom-boot/init.js', '');
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: ['custom-boot/init.js'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('resolves missing extensions in `bootScripts`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync('custom-boot/init.js', '');
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: ['./custom-boot/init'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('resolves missing extensions in `bootScripts` in module relative path',
      function(done) {
        appdir.createConfigFilesSync();
        var initJs = appdir.writeFileSync('node_modules/custom-boot/init.js',
          '');

        boot.compile(
          {
            appRootDir: appdir.PATH,
            bootScripts: ['custom-boot/init'],
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.bootScripts).to.eql([initJs]);
            done();
          }
        );
      });

    it('resolves module relative path for `bootScripts`', function(done) {
      appdir.createConfigFilesSync();
      var initJs = appdir.writeFileSync('node_modules/custom-boot/init.js', '');
      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: ['custom-boot/init.js'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([initJs]);
          done();
        }
      );
    });

    it('explores `bootScripts` in app relative path', function(done) {
      appdir.createConfigFilesSync();
      var appJs = appdir.writeFileSync('./custom-boot/init.js', '');

      appdir.writeFileSync('node_modules/custom-boot/init.js', '');

      boot.compile(
        {
          appRootDir: appdir.PATH,
          bootScripts: ['custom-boot/init.js'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.bootScripts).to.eql([appJs]);
          done();
        }
      );
    });

    it('ignores models/ subdirectory', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeFileSync('models/my-model.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.bootScripts).to.not.have.property('models');
        done();
      });
    });

    it('throws when models-config.json contains 1.x `properties`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            foo: {properties: {name: 'string'}},
          }
        );

        expectCompileToThrow(/unsupported 1\.x format/, done);
      });

    it('throws when model-config.json contains 1.x `options.base`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            Customer: {options: {base: 'User'}},
          }
        );

        expectCompileToThrow(/unsupported 1\.x format/, done);
      });

    it('loads models from `./models`', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {name: 'Car'});
      appdir.writeFileSync('models/car.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.eql({
          name: 'Car',
          config: {
            dataSource: 'db',
          },
          definition: {
            name: 'Car',
          },
          sourceFile: path.resolve(appdir.PATH, 'models', 'car.js'),
        });
        done();
      });
    });

    it('loads coffeescript models from `./models`', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {name: 'Car'});
      appdir.writeFileSync('models/car.coffee', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.eql({
          name: 'Car',
          config: {
            dataSource: 'db',
          },
          definition: {
            name: 'Car',
          },
          sourceFile: path.resolve(appdir.PATH, 'models', 'car.coffee'),
        });
        done();
      });
    });

    it('supports `modelSources` option', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('custom-models/car.json', {name: 'Car'});
      appdir.writeFileSync('custom-models/car.js', '');

      boot.compile(
        {
          appRootDir: appdir.PATH,
          modelSources: ['./custom-models'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0]).to.eql({
            name: 'Car',
            config: {
              dataSource: 'db',
            },
            definition: {
              name: 'Car',
            },
            sourceFile: path.resolve(appdir.PATH, 'custom-models', 'car.js'),
          });
          done();
        }
      );
    });

    it('supports `sources` option in `model-config.json`', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          _meta: {
            sources: ['./custom-models'],
          },
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('custom-models/car.json', {name: 'Car'});
      appdir.writeFileSync('custom-models/car.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.have.length(1);
        expect(instructions.models[0]).to.eql({
          name: 'Car',
          config: {
            dataSource: 'db',
          },
          definition: {
            name: 'Car',
          },
          sourceFile: path.resolve(appdir.PATH, 'custom-models', 'car.js'),
        });
        done();
      });
    });

    it('supports sources relative to node_modules', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          User: {dataSource: 'db'},
        }
      );

      boot.compile(
        {
          appRootDir: appdir.PATH,
          modelSources: [
            'loopback/common/models',
            'loopback/common/dir-does-not-exist',
          ],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0]).to.eql({
            name: 'User',
            config: {
              dataSource: 'db',
            },
            definition: require('loopback/common/models/user.json'),
            sourceFile: require.resolve('loopback/common/models/user.js'),
          });
          done();
        }
      );
    });

    it('resolves relative path in `modelSources` option', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('custom-models/car.json', {name: 'Car'});
      var appJS = appdir.writeFileSync('custom-models/car.js', '');

      boot.compile(
        {
          appRootDir: appdir.PATH,
          modelSources: ['./custom-models'],
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0].sourceFile).to.equal(appJS);
          done();
        }
      );
    });

    it('resolves module relative path in `modelSources` option',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            Car: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('node_modules/custom-models/car.json', {
          name: 'Car',
        });
        var appJS = appdir.writeFileSync('node_modules/custom-models/car.js',
          '');

        boot.compile(
          {
            appRootDir: appdir.PATH,
            modelSources: ['custom-models'],
          },
          function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            expect(instructions.models).to.have.length(1);
            expect(instructions.models[0].sourceFile).to.equal(appJS);
            done();
          }
        );
      });

    it('resolves relative path in `sources` option in `model-config.json`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            _meta: {
              sources: ['./custom-models'],
            },
            Car: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('custom-models/car.json', {name: 'Car'});
        var appJS = appdir.writeFileSync('custom-models/car.js', '');

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0].sourceFile).to.equal(appJS);
          done();
        });
      });

    it('resolves module relative path in `sources` option in model-config.json',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            _meta: {
              sources: ['custom-models'],
            },
            Car: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('node_modules/custom-models/car.json', {
          name: 'Car',
        });

        var appJS = appdir.writeFileSync('node_modules/custom-models/car.js',
          '');

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.models).to.have.length(1);
          expect(instructions.models[0].sourceFile).to.equal(appJS);
          done();
        });
      });

    it('handles model definitions with no code', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {name: 'Car'});

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.eql([
          {
            name: 'Car',
            config: {
              dataSource: 'db',
            },
            definition: {
              name: 'Car',
            },
            sourceFile: undefined,
          },
        ]);
        done();
      });
    });

    it('excludes models not listed in `model-config.json`', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {name: 'Car'});
      appdir.writeConfigFileSync('models/bar.json', {name: 'Bar'});

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var models = instructions.models.map(getNameProperty);
        expect(models).to.eql(['Car']);
        done();
      });
    });

    it('includes models used as Base models', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {
        name: 'Car',
        base: 'Vehicle',
      });
      appdir.writeConfigFileSync('models/vehicle.json', {
        name: 'Vehicle',
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        var models = instructions.models;
        var modelNames = models.map(getNameProperty);

        expect(modelNames).to.eql(['Vehicle', 'Car']);
        expect(models[0].config).to.equal(undefined);
        done();
      });
    });

    it('excludes pre-built base models', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {
        name: 'Car',
        base: 'Model',
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var modelNames = instructions.models.map(getNameProperty);
        expect(modelNames).to.eql(['Car']);
        done();
      });
    });

    it('sorts models, base models first', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Vehicle: {dataSource: 'db'},
          FlyingCar: {dataSource: 'db'},
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {
        name: 'Car',
        base: 'Vehicle',
      });
      appdir.writeConfigFileSync('models/vehicle.json', {
        name: 'Vehicle',
      });
      appdir.writeConfigFileSync('models/flying-car.json', {
        name: 'FlyingCar',
        base: 'Car',
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var modelNames = instructions.models.map(getNameProperty);
        expect(modelNames).to.eql(['Vehicle', 'Car', 'FlyingCar']);
        done();
      });
    });

    it('detects circular Model dependencies', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Vehicle: {dataSource: 'db'},
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {
        name: 'Car',
        base: 'Vehicle',
      });
      appdir.writeConfigFileSync('models/vehicle.json', {
        name: 'Vehicle',
        base: 'Car',
      });

      expectCompileToThrow(/cyclic dependency/i, done);
    });

    it('uses file name as default value for model name', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          Car: {dataSource: 'db'},
        }
      );
      appdir.writeConfigFileSync('models/car.json', {});

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var modelNames = instructions.models.map(getNameProperty);
        expect(modelNames).to.eql(['Car']);
        done();
      });
    });

    it('uses `OrderItem` as default model name for file with name `order-item`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            OrderItem: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('models/order-item.json', {});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          var modelNames = instructions.models.map(getNameProperty);
          expect(modelNames).to.eql(['OrderItem']);
          done();
        });
      });

    it('uses `OrderItem` as default model name for file with name `order_item`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            OrderItem: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('models/order_item.json', {});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          var modelNames = instructions.models.map(getNameProperty);
          expect(modelNames).to.eql(['OrderItem']);
          done();
        });
      });

    it('uses `OrderItem` as default model name for file with name `order item`',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            OrderItem: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('models/order item.json', {});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          var modelNames = instructions.models.map(getNameProperty);
          expect(modelNames).to.eql(['OrderItem']);
          done();
        });
      });

    it('overrides `default model name` by `name` in model definition',
      function(done) {
        appdir.createConfigFilesSync(
          {},
          {},
          {
            overrideCar: {dataSource: 'db'},
          }
        );
        appdir.writeConfigFileSync('models/car.json', {name: 'overrideCar'});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          var modelNames = instructions.models.map(getNameProperty);
          expect(modelNames).to.eql(['overrideCar']);
          done();
        });
      });

    it('overwrites model with same default name', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          OrderItem: {dataSource: 'db'},
        }
      );

      appdir.writeConfigFileSync('models/order-item.json', {
        properties: {
          price: {type: 'number'},
        },
      });
      appdir.writeFileSync('models/order-item.js', '');

      appdir.writeConfigFileSync('models/orderItem.json', {
        properties: {
          quantity: {type: 'number'},
        },
      });
      var appJS = appdir.writeFileSync('models/orderItem.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.eql([
          {
            name: 'OrderItem',
            config: {
              dataSource: 'db',
            },
            definition: {
              name: 'OrderItem',
              properties: {
                quantity: {type: 'number'},
              },
            },
            sourceFile: appJS,
          },
        ]);
        done();
      });
    });

    it('overwrites model with same name in model definition', function(done) {
      appdir.createConfigFilesSync(
        {},
        {},
        {
          customOrder: {dataSource: 'db'},
        }
      );

      appdir.writeConfigFileSync('models/order1.json', {
        name: 'customOrder',
        properties: {
          price: {type: 'number'},
        },
      });
      appdir.writeFileSync('models/order1.js', '');

      appdir.writeConfigFileSync('models/order2.json', {
        name: 'customOrder',
        properties: {
          quantity: {type: 'number'},
        },
      });
      var appJS = appdir.writeFileSync('models/order2.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.models).to.eql([
          {
            name: 'customOrder',
            config: {
              dataSource: 'db',
            },
            definition: {
              name: 'customOrder',
              properties: {
                quantity: {type: 'number'},
              },
            },
            sourceFile: appJS,
          },
        ]);
        done();
      });
    });

    it('returns a new copy of JSON data', function(done) {
      appdir.createConfigFilesSync();

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        instructions.application.modified = true;

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          expect(instructions.application).to.not.have.property('modified');
          done();
        });
      });
    });

    describe('for mixins', function() {
      describe(' - mixinDirs', function(done) {
        function verifyMixinIsFoundViaMixinDirs(sourceFile, mixinDirs, done) {
          var appJS = appdir.writeFileSync(sourceFile, '');

          boot.compile(
            {
              appRootDir: appdir.PATH,
              mixinDirs: mixinDirs,
            },
            function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            }
          );
        }

        it('supports `mixinDirs` option', function(done) {
          verifyMixinIsFoundViaMixinDirs(
            'custom-mixins/other.js',
            ['./custom-mixins'],
            done
          );
        });

        it('resolves relative path in `mixinDirs` option', function(done) {
          verifyMixinIsFoundViaMixinDirs(
            'custom-mixins/other.js',
            ['./custom-mixins'],
            done
          );
        });

        it('resolves module relative path in `mixinDirs` option',
          function(done) {
            verifyMixinIsFoundViaMixinDirs(
              'node_modules/custom-mixins/other.js',
              ['custom-mixins'],
              done
            );
          });
      });

      describe(' - mixinSources', function() {
        beforeEach(function() {
          appdir.createConfigFilesSync(
            {},
            {},
            {
              Car: {dataSource: 'db'},
            }
          );
          appdir.writeConfigFileSync('models/car.json', {
            name: 'Car',
            mixins: {TimeStamps: {}},
          });
        });

        function verifyMixinIsFoundViaMixinSources(
          sourceFile,
          mixinSources,
          done
        ) {
          var appJS = appdir.writeFileSync(sourceFile, '');

          boot.compile(
            {
              appRootDir: appdir.PATH,
              mixinSources: mixinSources,
            },
            function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            }
          );
        }

        it('supports `mixinSources` option', function(done) {
          verifyMixinIsFoundViaMixinSources(
            'mixins/time-stamps.js',
            ['./mixins'],
            done
          );
        });

        it('resolves relative path in `mixinSources` option', function(done) {
          verifyMixinIsFoundViaMixinSources(
            'custom-mixins/time-stamps.js',
            ['./custom-mixins'],
            done
          );
        });

        it('resolves module relative path in `mixinSources` option',
          function(done) {
            verifyMixinIsFoundViaMixinSources(
              'node_modules/custom-mixins/time-stamps.js',
              ['custom-mixins'],
              done
            );
          });

        it('supports `mixins` option in `model-config.json`', function(done) {
          appdir.createConfigFilesSync(
            {},
            {},
            {
              _meta: {
                mixins: ['./custom-mixins'],
              },
              Car: {
                dataSource: 'db',
              },
            }
          );

          var appJS = appdir.writeFileSync('custom-mixins/time-stamps.js', '');
          boot.compile(appdir.PATH, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.mixins[0].sourceFile).to.eql(appJS);
            done();
          });
        });

        it('sets by default `mixinSources` to `mixins` directory',
          function(done) {
            var appJS = appdir.writeFileSync('mixins/time-stamps.js', '');
            boot.compile(appdir.PATH, function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            });
          });

        it('loads only mixins used by models', function(done) {
          var appJS = appdir.writeFileSync('mixins/time-stamps.js', '');
          appdir.writeFileSync('mixins/foo.js', '');

          boot.compile(appdir.PATH, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.mixins).to.have.length(1);
            expect(instructions.mixins[0].sourceFile).to.eql(appJS);
            done();
          });
        });

        it('loads mixins from model using mixin name in JSON file',
          function(done) {
            var appJS = appdir.writeFileSync('mixins/time-stamps.js', '');
            appdir.writeConfigFileSync('mixins/time-stamps.json', {
              name: 'Timestamping',
            });

            appdir.writeConfigFileSync('models/car.json', {
              name: 'Car',
              mixins: {Timestamping: {}},
            });

            boot.compile(appdir.PATH, function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins).to.have.length(1);
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            });
          });

        it('loads mixin only once for dirs common to mixinDirs & mixinSources',
          function(done) {
            var appJS = appdir.writeFileSync('custom-mixins/time-stamps.js',
              '');

            var options = {
              appRootDir: appdir.PATH,
              mixinDirs: ['./custom-mixins'],
              mixinSources: ['./custom-mixins'],
            };

            boot.compile(options, function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins).to.have.length(1);
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            });
          });

        it('loads mixin from mixinSources, when it is also found in mixinDirs',
          function(done) {
            appdir.writeFileSync('mixinDir/time-stamps.js', '');
            var appJS = appdir.writeFileSync('mixinSource/time-stamps.js', '');

            var options = {
              appRootDir: appdir.PATH,
              mixinDirs: ['./mixinDir'],
              mixinSources: ['./mixinSource'],
            };

            boot.compile(options, function(err, context) {
              if (err) return done(err);
              var instructions = context.instructions;
              expect(instructions.mixins).to.have.length(1);
              expect(instructions.mixins[0].sourceFile).to.eql(appJS);
              done();
            });
          });

        it('loads mixin from the most recent mixin definition', function(done) {
          appdir.writeFileSync('mixins1/time-stamps.js', '');
          var mixins2 = appdir.writeFileSync('mixins2/time-stamps.js', '');

          var options = {
            appRootDir: appdir.PATH,
            mixinSources: ['./mixins1', './mixins2'],
          };

          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;
            expect(instructions.mixins).to.have.length(1);
            expect(instructions.mixins[0].sourceFile).to.eql(mixins2);
            done();
          });
        });
      });

      describe('name normalization', function() {
        var options;
        beforeEach(function() {
          options = {appRootDir: appdir.PATH, mixinDirs: ['./custom-mixins']};

          appdir.writeFileSync('custom-mixins/foo.js', '');
          appdir.writeFileSync('custom-mixins/time-stamps.js', '');
          appdir.writeFileSync('custom-mixins/camelCase.js', '');
          appdir.writeFileSync('custom-mixins/PascalCase.js', '');
          appdir.writeFileSync('custom-mixins/space name.js', '');
        });

        it('supports classify', function(done) {
          options.normalization = 'classify';
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'CamelCase',
              'Foo',
              'PascalCase',
              'SpaceName',
              'TimeStamps',
            ]);
            done();
          });
        });

        it('supports dasherize', function(done) {
          options.normalization = 'dasherize';
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'camel-case',
              'foo',
              'pascal-case',
              'space-name',
              'time-stamps',
            ]);
            done();
          });
        });

        it('supports custom function', function(done) {
          var normalize = function(name) {
            return name.toUpperCase();
          };
          options.normalization = normalize;
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'CAMELCASE',
              'FOO',
              'PASCALCASE',
              'SPACE NAME',
              'TIME-STAMPS',
            ]);
            done();
          });
        });

        it('supports none', function(done) {
          options.normalization = 'none';
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'camelCase',
              'foo',
              'PascalCase',
              'space name',
              'time-stamps',
            ]);
            done();
          });
        });

        it('supports false', function(done) {
          options.normalization = false;
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'camelCase',
              'foo',
              'PascalCase',
              'space name',
              'time-stamps',
            ]);
            done();
          });
        });

        it('defaults to classify', function(done) {
          boot.compile(options, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            var mixins = instructions.mixins;
            var mixinNames = mixins.map(getNameProperty);

            expect(mixinNames).to.eql([
              'CamelCase',
              'Foo',
              'PascalCase',
              'SpaceName',
              'TimeStamps',
            ]);
            done();
          });
        });

        it('throws error for invalid normalization format', function(done) {
          options.normalization = 'invalidFormat';

          expectCompileToThrow(
            /Invalid normalization format - "invalidFormat"/,
            options,
            done
          );
        });
      });

      it('overrides default mixin name, by `name` in JSON', function(done) {
        appdir.writeFileSync('mixins/foo.js', '');
        appdir.writeConfigFileSync('mixins/foo.json', {name: 'fooBar'});

        var options = {
          appRootDir: appdir.PATH,
          mixinDirs: ['./mixins'],
        };
        boot.compile(options, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.mixins[0].name).to.eql('fooBar');
          done();
        });
      });

      it('extends definition from JSON with same file name', function(done) {
        var appJS = appdir.writeFileSync('custom-mixins/foo-bar.js', '');

        appdir.writeConfigFileSync('custom-mixins/foo-bar.json', {
          description: 'JSON file name same as JS file name',
        });
        appdir.writeConfigFileSync('custom-mixins/FooBar.json', {
          description: 'JSON file name same as normalized name of mixin',
        });

        var options = {
          appRootDir: appdir.PATH,
          mixinDirs: ['./custom-mixins'],
          normalization: 'classify',
        };
        boot.compile(options, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.mixins).to.eql([
            {
              name: 'FooBar',
              description: 'JSON file name same as JS file name',
              sourceFile: appJS,
            },
          ]);
        });
        done();
      });
    });
  });

  describe('for middleware', function() {
    function testMiddlewareRegistration(middlewareId, sourceFile, done) {
      var json = {
        initial: {},
        custom: {},
      };

      json.custom[middlewareId] = {
        params: 'some-config-data',
      };

      appdir.writeConfigFileSync('middleware.json', json);

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware).to.eql({
          phases: ['initial', 'custom'],
          middleware: [
            {
              sourceFile: sourceFile,
              config: {
                phase: 'custom',
                params: 'some-config-data',
              },
            },
          ],
        });
        done();
      });
    }

    var sourceFileForUrlNotFound;
    beforeEach(function() {
      fs.copySync(SIMPLE_APP, appdir.PATH);
      sourceFileForUrlNotFound = require.resolve(
        'loopback/server/middleware/url-not-found'
      );
    });

    it('emits middleware instructions', function(done) {
      testMiddlewareRegistration(
        'loopback/server/middleware/url-not-found',
        sourceFileForUrlNotFound,
        done
      );
    });

    it('emits middleware instructions for fragment', function(done) {
      testMiddlewareRegistration(
        'loopback#url-not-found',
        sourceFileForUrlNotFound,
        done
      );
    });

    it('supports `middlewareRootDir` option', function(done) {
      var middlewareJson = {
        initial: {},
        custom: {
          'loopback/server/middleware/url-not-found': {
            params: 'some-config-data',
          },
        },
      };
      var customDir = path.resolve(appdir.PATH, 'custom');
      fs.mkdirsSync(customDir);
      fs.writeJsonSync(
        path.resolve(customDir, 'middleware.json'),
        middlewareJson
      );
      boot.compile(
        {
          appRootDir: appdir.PATH,
          middlewareRootDir: customDir,
        },
        function(err, context) {
          var instructions = context.instructions;
          expect(instructions.middleware).to.eql({
            phases: ['initial', 'custom'],
            middleware: [
              {
                sourceFile: sourceFileForUrlNotFound,
                config: {
                  phase: 'custom',
                  params: 'some-config-data',
                },
              },
            ],
          });
          done();
        }
      );
    });

    it('fails when a module middleware cannot be resolved', function(done) {
      appdir.writeConfigFileSync('middleware.json', {
        final: {
          'loopback/path-does-not-exist': {},
        },
      });

      expectCompileToThrow(/path-does-not-exist/, done);
    });

    it('does not fail when an optional middleware cannot be resolved',
      function(done) {
        appdir.writeConfigFileSync('middleware.json', {
          final: {
            'loopback/path-does-not-exist': {
              optional: 'this middleware is optional',
            },
          },
        });

        expectCompileToNotThrow(done);
      });

    it('fails when a module middleware fragment cannot be resolved',
      function(done) {
        appdir.writeConfigFileSync('middleware.json', {
          final: {
            'loopback#path-does-not-exist': {},
          },
        });

        expectCompileToThrow(/path-does-not-exist/, done);
      });

    it('does not fail when an optional middleware fragment cannot be resolved',
      function(done) {
        appdir.writeConfigFileSync('middleware.json', {
          final: {
            'loopback#path-does-not-exist': {
              optional: 'this middleware is optional',
            },
          },
        });

        expectCompileToNotThrow(done);
      });

    it('resolves paths relatively to appRootDir', function(done) {
      appdir.writeFileSync('my-middleware.js', '');
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          // resolves to ./my-middleware.js
          './my-middleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware).to.eql({
          phases: ['routes'],
          middleware: [
            {
              sourceFile: path.resolve(appdir.PATH, 'my-middleware.js'),
              config: {phase: 'routes'},
            },
          ],
        });
        done();
      });
    });

    it('merges config.params', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': {
            params: {
              key: 'initial value',
            },
          },
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': {
            params: {
              key: 'custom value',
            },
          },
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expectFirstMiddlewareParams(instructions).to.eql({
          key: 'custom value',
        });
        done();
      });
    });

    it('merges config.enabled', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': {
            params: {
              key: 'initial value',
            },
          },
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': {
            enabled: false,
          },
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0].config).to.have.property(
          'enabled',
          false
        );
        done();
      });
    });

    function verifyMiddlewareConfig(done) {
      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.eql([
          {
            sourceFile: path.resolve(appdir.PATH, 'middleware'),
            config: {
              phase: 'routes',
              params: {
                key: 'initial value',
              },
            },
          },
          {
            sourceFile: path.resolve(appdir.PATH, 'middleware'),
            config: {
              phase: 'routes',
              params: {
                key: 'custom value',
              },
            },
          },
        ]);
        done();
      });
    }

    it('merges config.params array to array', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': [
            {
              params: {
                key: 'initial value',
              },
            },
          ],
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': [
            {
              params: {
                key: 'custom value',
              },
            },
          ],
        },
      });

      verifyMiddlewareConfig(done);
    });

    it('merges config.params array to object', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': {
            params: {
              key: 'initial value',
            },
          },
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': [
            {
              params: {
                key: 'custom value',
              },
            },
          ],
        },
      });

      verifyMiddlewareConfig(done);
    });

    it('merges config.params object to array', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': [
            {
              params: {
                key: 'initial value',
              },
            },
          ],
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': {
            params: {
              key: 'custom value',
            },
          },
        },
      });

      verifyMiddlewareConfig(done);
    });

    it('merges config.params array to empty object', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': {},
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': [
            {
              params: {
                key: 'custom value',
              },
            },
          ],
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.eql([
          {
            sourceFile: path.resolve(appdir.PATH, 'middleware'),
            config: {
              phase: 'routes',
              params: {
                key: 'custom value',
              },
            },
          },
        ]);
      });
      done();
    });

    it('merges config.params array to array by name', function(done) {
      appdir.writeConfigFileSync('./middleware.json', {
        routes: {
          './middleware': [
            {
              name: 'a',
              params: {
                key: 'initial value',
              },
            },
          ],
        },
      });

      appdir.writeConfigFileSync('./middleware.local.json', {
        routes: {
          './middleware': [
            {
              name: 'a',
              params: {
                key: 'custom value',
              },
            },
            {
              params: {
                key: '2nd value',
              },
            },
          ],
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.eql([
          {
            sourceFile: path.resolve(appdir.PATH, 'middleware'),
            config: {
              name: 'a',
              phase: 'routes',
              params: {
                key: 'custom value',
              },
            },
          },
          {
            sourceFile: path.resolve(appdir.PATH, 'middleware'),
            config: {
              phase: 'routes',
              params: {
                key: '2nd value',
              },
            },
          },
        ]);
        done();
      });
    });

    it('flattens sub-phases', function(done) {
      appdir.writeConfigFileSync('middleware.json', {
        'initial:after': {},
        'custom:before': {
          'loopback/server/middleware/url-not-found': {
            params: 'some-config-data',
          },
        },
        'custom:after': {},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.phases, 'phases').to.eql([
          'initial',
          'custom',
        ]);
        expect(instructions.middleware.middleware, 'middleware').to.eql([
          {
            sourceFile: require.resolve(
              'loopback/server/middleware/url-not-found'
            ),
            config: {
              phase: 'custom:before',
              params: 'some-config-data',
            },
          },
        ]);
        done();
      });
    });

    it('supports multiple instances of the same middleware', function(done) {
      appdir.writeFileSync('my-middleware.js', '');
      appdir.writeConfigFileSync('middleware.json', {
        final: {
          './my-middleware': [
            {
              params: 'first',
            },
            {
              params: 'second',
            },
          ],
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.eql([
          {
            sourceFile: path.resolve(appdir.PATH, 'my-middleware.js'),
            config: {
              phase: 'final',
              params: 'first',
            },
          },
          {
            sourceFile: path.resolve(appdir.PATH, 'my-middleware.js'),
            config: {
              phase: 'final',
              params: 'second',
            },
          },
        ]);
        done();
      });
    });

    it('supports shorthand notation for middleware paths', function(done) {
      appdir.writeConfigFileSync('middleware.json', {
        final: {
          'loopback#url-not-found': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0].sourceFile).to.equal(
          require.resolve('loopback/server/middleware/url-not-found')
        );
        done();
      });
    });

    it('supports shorthand notation for relative paths', function(done) {
      appdir.writeConfigFileSync('middleware.json', {
        routes: {
          './middleware/index#myMiddleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0].sourceFile).to.equal(
          path.resolve(appdir.PATH, './middleware/index.js')
        );
        expect(instructions.middleware.middleware[0]).have.property(
          'fragment',
          'myMiddleware'
        );
        done();
      });
    });

    it('supports shorthand notation when the fragment name matches a property',
      function(done) {
        appdir.writeConfigFileSync('middleware.json', {
          final: {
            'loopback#errorHandler': {},
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.middleware.middleware[0]).have.property(
            'sourceFile',
            pathWithoutIndex(require.resolve('loopback'))
          );
          expect(instructions.middleware.middleware[0]).have.property(
            'fragment',
            'errorHandler'
          );
          done();
        });
      });

    it('resolves modules relative to appRootDir', function(done) {
      var HANDLER_FILE = 'node_modules/handler/index.js';
      appdir.writeFileSync(
        HANDLER_FILE,
        'module.exports = function(req, res, next) { next(); }'
      );

      appdir.writeConfigFileSync('middleware.json', {
        initial: {
          handler: {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0]).have.property(
          'sourceFile',
          pathWithoutIndex(appdir.resolve(HANDLER_FILE))
        );
        done();
      });
    });

    it('prefers appRootDir over node_modules for middleware', function(done) {
      var appJS = appdir.writeFileSync('./my-middleware.js', '');
      appdir.writeFileSync('node_modules/my-middleware.js', '');
      appdir.writeConfigFileSync('middleware.json', {
        routes: {
          './my-middleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.have.length(1);
        expect(instructions.middleware.middleware[0]).have.property(
          'sourceFile',
          appJS
        );
        done();
      });
    });

    it('does not treat module relative path as `appRootDir` relative',
      function(done) {
        appdir.writeFileSync('./my-middleware.js', '');
        var moduleJS = appdir.writeFileSync('node_modules/my-middleware.js',
          '');
        appdir.writeConfigFileSync('middleware.json', {
          routes: {
            'my-middleware': {},
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.middleware.middleware).to.have.length(1);
          expect(instructions.middleware.middleware[0]).have.property(
            'sourceFile',
            moduleJS
          );
          done();
        });
      });

    it('loads middleware from coffeescript in appRootdir', function(done) {
      var coffee = appdir.writeFileSync('my-middleware.coffee', '');
      appdir.writeConfigFileSync('middleware.json', {
        routes: {
          './my-middleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0]).have.property(
          'sourceFile',
          coffee
        );
        done();
      });
    });

    it('loads coffeescript from middleware under node_modules', function(done) {
      var file = appdir.writeFileSync(
        'node_modules/my-middleware/index.coffee',
        ''
      );
      appdir.writeFileSync('node_modules/my-middleware/index.json', '');
      appdir.writeConfigFileSync('middleware.json', {
        routes: {
          'my-middleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware).to.have.length(1);
        expect(instructions.middleware.middleware[0]).have.property(
          'sourceFile',
          pathWithoutIndex(file)
        );
        done();
      });
    });

    it('prefers coffeescript over json for relative middleware path',
      function(done) {
        var coffee = appdir.writeFileSync('my-middleware.coffee', '');
        appdir.writeFileSync('my-middleware.json', '');
        appdir.writeConfigFileSync('middleware.json', {
          routes: {
            './my-middleware': {},
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.middleware.middleware).to.have.length(1);
          expect(instructions.middleware.middleware[0]).have.property(
            'sourceFile',
            coffee
          );
          done();
        });
      });

    it('prefers coffeescript over json for module relative middleware path',
      function(done) {
        var coffee = appdir.writeFileSync(
          'node_modules/my-middleware.coffee',
          ''
        );
        appdir.writeFileSync('node_modules/my-middleware.json', '');
        appdir.writeConfigFileSync('middleware.json', {
          routes: {
            'my-middleware': {},
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.middleware.middleware).to.have.length(1);
          expect(instructions.middleware.middleware[0]).have.property(
            'sourceFile',
            coffee
          );
          done();
        });
      });

    it('ignores sourcmap files when loading middleware', function(done) {
      var middleware = appdir.writeFileSync(
        'my-middleware.js',
        '// I am the middleware'
      );
      var sourcemap = appdir.writeFileSync(
        'my-middleware.js.map',
        '// I am a sourcemap'
      );
      appdir.writeConfigFileSync('middleware.json', {
        routes: {
          './my-middleware': {},
        },
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        expect(instructions.middleware.middleware[0]).have.property(
          'sourceFile',
          middleware
        );
        done();
      });
    });

    describe('config with relative paths in params', function() {
      var RELATIVE_PATH_PARAMS = ['$!./here', '$!../there'];

      var absolutePathParams;
      beforeEach(function resolveRelativePathParams() {
        absolutePathParams = RELATIVE_PATH_PARAMS.map(function(p) {
          return appdir.resolve(p.slice(2));
        });
      });

      it('converts paths in top-level array items', function(done) {
        givenMiddlewareEntrySync({params: RELATIVE_PATH_PARAMS});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expectFirstMiddlewareParams(instructions).to.eql(absolutePathParams);
          done();
        });
      });

      it('converts paths in top-level object properties', function(done) {
        givenMiddlewareEntrySync({
          params: {
            path: RELATIVE_PATH_PARAMS[0],
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expectFirstMiddlewareParams(instructions).to.eql({
            path: absolutePathParams[0],
          });
          done();
        });
      });

      it('converts path value when params is a string', function(done) {
        givenMiddlewareEntrySync({params: RELATIVE_PATH_PARAMS[0]});

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expectFirstMiddlewareParams(instructions).to.eql(
            absolutePathParams[0]
          );
          done();
        });
      });

      it('converts paths in nested properties', function(done) {
        givenMiddlewareEntrySync({
          params: {
            nestedObject: {
              path: RELATIVE_PATH_PARAMS[0],
            },
            nestedArray: RELATIVE_PATH_PARAMS,
          },
        });

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expectFirstMiddlewareParams(instructions).to.eql({
            nestedObject: {
              path: absolutePathParams[0],
            },
            nestedArray: absolutePathParams,
          });
          done();
        });
      });

      it('does not convert values not starting with `./` or `../`',
        function(done) {
          var PARAMS = ['$!.somerc', '$!/root', '$!hello!'];
          givenMiddlewareEntrySync({params: PARAMS});

          boot.compile(appdir.PATH, function(err, context) {
            if (err) return done(err);
            var instructions = context.instructions;

            expectFirstMiddlewareParams(instructions).to.eql(PARAMS);
            done();
          });
        });
    });
  });

  describe('for components', function() {
    it('loads component configs from multiple files', function(done) {
      appdir.createConfigFilesSync();
      appdir.writeConfigFileSync('component-config.json', {
        debug: {option: 'value'},
      });
      appdir.writeConfigFileSync('component-config.local.json', {
        debug: {local: 'applied'},
      });

      var env = process.env.NODE_ENV || 'development';
      appdir.writeConfigFileSync('component-config.' + env + '.json', {
        debug: {env: 'applied'},
      });

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;

        var component = instructions.components[0];
        expect(component).to.eql({
          sourceFile: require.resolve('debug'),
          config: {
            option: 'value',
            local: 'applied',
            env: 'applied',
          },
        });
        done();
      });
    });

    it('supports `componentRootDir` option', function(done) {
      var componentJson = {
        debug: {
          option: 'value',
        },
      };
      var customDir = path.resolve(appdir.PATH, 'custom');
      fs.mkdirsSync(customDir);
      fs.writeJsonSync(
        path.resolve(customDir, 'component-config.json'),
        componentJson
      );

      boot.compile(
        {
          appRootDir: appdir.PATH,
          componentRootDir: path.resolve(appdir.PATH, 'custom'),
        },
        function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;
          var component = instructions.components[0];
          expect(component).to.eql({
            sourceFile: require.resolve('debug'),
            config: {
              option: 'value',
            },
          });
          done();
        }
      );
    });

    it('loads component relative to appRootDir', function(done) {
      appdir.writeConfigFileSync('./component-config.json', {
        './index': {},
      });
      var appJS = appdir.writeConfigFileSync('index.js', '');

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        expect(instructions.components[0]).have.property('sourceFile', appJS);
        done();
      });
    });

    it('loads component relative to node modules', function(done) {
      appdir.writeConfigFileSync('component-config.json', {
        mycomponent: {},
      });
      var js = appdir.writeConfigFileSync(
        'node_modules/mycomponent/index.js',
        ''
      );

      boot.compile(appdir.PATH, function(err, context) {
        if (err) return done(err);
        var instructions = context.instructions;
        expect(instructions.components[0]).have.property('sourceFile', js);
        done();
      });
    });

    it('retains backward compatibility for non-relative path in `appRootDir`',
      function(done) {
        appdir.writeConfigFileSync('component-config.json', {
          'my-component/component.js': {},
        });
        appdir.writeConfigFileSync('./my-component/component.js', '');

        expectCompileToThrow(
          'Cannot resolve path "my-component/component.js"',
          done
        );
      });

    it('prefers coffeescript over json for relative path component',
      function(done) {
        appdir.writeConfigFileSync('component-config.json', {
          './component': {},
        });

        var coffee = appdir.writeFileSync('component.coffee', '');
        appdir.writeFileSync('component.json', '');

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.components).to.have.length(1);
          expect(instructions.components[0]).have.property(
            'sourceFile',
            coffee
          );
          done();
        });
      });

    it('prefers coffeescript over json for module relative component path',
      function(done) {
        appdir.writeConfigFileSync('component-config.json', {
          component: {},
        });

        var coffee = appdir.writeFileSync('node_modules/component.coffee', '');
        appdir.writeFileSync('node_modules/component.json', '');

        boot.compile(appdir.PATH, function(err, context) {
          if (err) return done(err);
          var instructions = context.instructions;

          expect(instructions.components).to.have.length(1);
          expect(instructions.components[0]).have.property(
            'sourceFile',
            coffee
          );
          done();
        });
      });
  });
});

function getNameProperty(obj) {
  return obj.name;
}

function givenMiddlewareEntrySync(config) {
  appdir.writeConfigFileSync('middleware.json', {
    initial: {
      // resolves to ./middleware.json
      './middleware': config,
    },
  });
}

function expectFirstMiddlewareParams(instructions) {
  return expect(instructions.middleware.middleware[0].config.params);
}

function pathWithoutExtension(value) {
  return path.join(
    path.dirname(value),
    path.basename(value, path.extname(value))
  );
}

function pathWithoutIndex(filePath) {
  return filePath.replace(/[\\\/]index\.[^.]+$/, '');
}