'use strict';
var crypto = require('crypto');
var path = require('path');
var os = require('os');
var assert = require('assert');
var _ = require('lodash');
var util = require('util');
var rimraf = require('rimraf');
var EventEmitter = require('events').EventEmitter;
var helpers = require('./');
var RunResult = require('./run-result');
/**
* Wrap callback so it can't get called twice
*/
const callbackWrapper = done => {
let callbackHandled = false;
const callback = err => {
if (!callbackHandled) {
callbackHandled = true;
done(err);
}
};
return callback;
};
/**
* This class provide a run context object to façade the complexity involved in setting
* up a generator for testing
* @constructor
* @param {String|Function} Generator - Namespace or generator constructor. If the later
* is provided, then namespace is assumed to be
* 'gen:test' in all cases
* @param {Object} [settings]
* @param {Boolean} [settings.tmpdir=true] - Automatically run this generator in a tmp dir
* @param {String} [settings.resolved] - File path to the generator (only used if Generator is a constructor)
* @param {String} [settings.namespace='gen:test'] - Namespace (only used if Generator is a constructor)
* @param {String} [settings.runEnvironment=false] - Require the run context to call run.
* @param {Object} [envOptions] - Options to be passed to environment.
* @return {this}
*/
function RunContext(Generator, settings, envOptions = {}) {
this._asyncHolds = 0;
this.ran = false;
this.inDirSet = false;
this.args = [];
this.options = {};
this.answers = {};
this.localConfig = null;
this.dependencies = [];
this.Generator = Generator;
this.inDirCallbacks = [];
this.lookups = [];
this.settings = _.extend(
{ tmpdir: true, namespace: 'gen:test', runEnvironment: false },
settings
);
this.envOptions = envOptions;
this.withOptions({
force: true,
skipCache: true,
skipInstall: true
});
if (!this.settings.runEnvironment) {
setTimeout(this._run.bind(this), 10);
}
}
util.inherits(RunContext, EventEmitter);
/**
* Hold the execution until the returned callback is triggered
* @return {Function} Callback to notify the normal execution can resume
*/
RunContext.prototype.async = function() {
this._asyncHolds++;
return function() {
this._asyncHolds--;
this._run();
}.bind(this);
};
/**
* Method called when the context is ready to run the generator
* @private
*/
RunContext.prototype._run = function() {
if (!this.inDirSet && this.settings.tmpdir) {
this.inTmpDir();
}
if (this._asyncHolds !== 0 || this.ran || this.completed) {
return false;
}
this.ran = true;
if (this.inDirCallbacks.length) {
const targetDirectory = path.resolve(this.targetDirectory);
this.inDirCallbacks.forEach(cb => cb(targetDirectory));
}
const envOptions = { ...this.envOptions };
if (this.settings.runEnvironment) {
envOptions.newErrorHandler = true;
}
const testEnv = helpers.createTestEnv(this.envOptions.createEnv, envOptions);
this.env = this.envCB ? this.envCB(testEnv) || testEnv : testEnv;
this.lookups.forEach(lookup => {
this.env.lookup(lookup);
});
helpers.registerDependencies(this.env, this.dependencies);
let namespace;
if (typeof this.Generator === 'string') {
if (this.settings.runEnvironment) {
namespace = this.Generator;
} else {
namespace = this.env.namespace(this.Generator);
if (namespace !== this.Generator) {
// Generator is a file path, it should be registered.
this.env.register(this.Generator);
}
}
} else {
namespace = this.settings.namespace;
this.env.registerStub(this.Generator, namespace, this.settings.resolved);
}
this.generator = this.env.create(namespace, {
arguments: this.args,
options: this.options
});
helpers.mockPrompt(this.env, this.answers, this.promptCallback);
if (this.localConfig) {
// Only mock local config when withLocalConfig was called
helpers.mockLocalConfig(this.generator, this.localConfig);
}
const endCallback = callbackWrapper(envOrGenerator => {
helpers.restorePrompt(envOrGenerator);
this.emit('end');
this.completed = true;
});
if (this.settings.runEnvironment) {
this.env.once('end', () => endCallback(this.env));
return true;
}
const callback = callbackWrapper(
function(err) {
helpers.restorePrompt(this.env);
this.emit('error', err);
}.bind(this)
);
this.generator.on('error', callback);
this.generator.once('end', () => endCallback(this.generator));
this.emit('ready', this.generator);
this.generator.run().catch(callback);
return true;
};
/**
* Build the generator and the environment.
* @return {RunContext} this
*/
RunContext.prototype.build = function() {
if (!this._run()) {
throw new Error('The context is not ready');
}
return this;
};
/**
* Run the generator on the environment and returns the promise.
* @return {Promise} Promise
*/
RunContext.prototype.run = function() {
if (!this.settings.runEnvironment) {
throw new Error('Should be called with runEnvironment option');
}
if (!this.ran) {
this.build();
}
return this.env
.runGenerator(this.generator)
.then(
() => new RunResult({ env: this.env, cwd: this.targetDirectory, fs: this.env.fs })
);
};
/**
* Return a promise representing the generator run process
* @return {Promise} Promise resolved on end or rejected on error
*/
RunContext.prototype.toPromise = function() {
if (this.settings.runEnvironment) {
throw new Error('RunContext with runEnvironment uses promises by default');
}
return new Promise(
function(resolve, reject) {
this.on(
'end',
function() {
resolve(this.targetDirectory);
}.bind(this)
);
this.on('error', reject);
}.bind(this)
);
};
/**
* Promise `.then()` duck typing
* @return {Promise}
*/
RunContext.prototype.then = function() {
var promise = this.toPromise();
return promise.then.apply(promise, arguments);
};
/**
* Promise `.catch()` duck typing
* @return {Promise}
*/
RunContext.prototype.catch = function() {
var promise = this.toPromise();
return promise.catch.apply(promise, arguments);
};
/**
* Set the target directory.
* @private
* @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
* file path for predictable results
* @return {this} run context instance
*/
RunContext.prototype.setDir = function(dirPath) {
if (this.inDirSet) {
this.completed = true;
throw new Error('Test directory has already been set.');
}
this.inDirSet = true;
this.targetDirectory = dirPath;
return this;
};
/**
* Clean the provided directory, then change directory into it
* @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
* file path for predictable results
* @param {Function} [cb] - callback who'll receive the folder path as argument
* @return {this} run context instance
*/
RunContext.prototype.inDir = function(dirPath, cb) {
this.setDir(dirPath);
helpers.testDirectory(dirPath, (cb || _.noop).bind(this, path.resolve(dirPath)));
return this;
};
/**
* Register an callback to prepare the destination folder.
* @param {Function} [cb] - callback who'll receive the folder path as argument
* @return {this} run context instance
*/
RunContext.prototype.doInDir = function(cb) {
this.inDirCallbacks.push(cb);
return this;
};
/**
* Change directory without deleting directory content.
* @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
* file path for predictable results
* @return {this} run context instance
*/
RunContext.prototype.cd = function(dirPath) {
this.setDir(dirPath);
dirPath = path.resolve(dirPath);
try {
process.chdir(dirPath);
} catch (err) {
this.completed = true;
throw new Error(err.message + ' ' + dirPath);
}
return this;
};
/**
* Cleanup a temporary directy and change the CWD into it
*
* This method is called automatically when creating a RunContext. Only use it if you need
* to use the callback.
*
* @param {Function} [cb] - callback who'll receive the folder path as argument
* @return {this} run context instance
*/
RunContext.prototype.inTmpDir = function(cb) {
var tmpdir = path.join(os.tmpdir(), crypto.randomBytes(20).toString('hex'));
return this.inDir(tmpdir, cb);
};
/**
* Create an environment
*
* This method is called automatically when creating a RunContext. Only use it if you need
* to use the callback.
*
* @param {Function} [cb] - callback who'll receive the folder path as argument
* @return {this} run context instance
*/
RunContext.prototype.withEnvironment = function(cb) {
this.envCB = cb;
return this;
};
/**
* Run lookup on the environment.
*
* @param {Object|Array} [lookups] - lookup to run.
* @return {this} run context instance.
*/
RunContext.prototype.withLookups = function(lookups) {
lookups = Array.isArray(lookups) ? lookups : [lookups];
this.lookups = this.lookups.concat(lookups);
return this;
};
/**
* Clean the directory used for tests inside inDir/inTmpDir
*/
RunContext.prototype.cleanTestDirectory = function() {
if (!this.settings.tmpdir) {
throw new Error('Cleanup test dir called with false tmpdir option.');
}
if (this.targetDirectory) {
rimraf.sync(this.targetDirectory);
}
};
/**
* Provide arguments to the run context
* @param {String|Array} args - command line arguments as Array or space separated string
* @return {this}
*/
RunContext.prototype.withArguments = function(args) {
var argsArray = _.isString(args) ? args.split(' ') : args;
assert(
_.isArray(argsArray),
'args should be either a string separated by spaces or an array'
);
this.args = this.args.concat(argsArray);
return this;
};
/**
* Provide options to the run context
* @param {Object} options - command line options (e.g. `--opt-one=foo`)
* @return {this}
*/
RunContext.prototype.withOptions = function(options) {
// Add options as both kebab and camel case. This is to stay backward compatibles with
// the switch we made to meow for options parsing.
Object.keys(options).forEach(function(key) {
options[_.camelCase(key)] = options[key];
options[_.kebabCase(key)] = options[key];
});
this.options = _.extend(this.options, options);
return this;
};
/**
* Mock the prompt with dummy answers
* @param {Object} answers - Answers to the prompt questions
* @return {this}
*/
RunContext.prototype.withPrompts = function(answers, callback) {
this.answers = _.extend(this.answers, answers);
this.promptCallback = callback;
return this;
};
/**
* Provide dependent generators
* @param {Array} dependencies - paths to the generators dependencies
* @return {this}
* @example
* var angular = new RunContext('../../app');
* angular.withGenerators([
* '../../common',
* '../../controller',
* '../../main',
* [helpers.createDummyGenerator(), 'testacular:app']
* ]);
* angular.on('end', function () {
* // assert something
* });
*/
RunContext.prototype.withGenerators = function(dependencies) {
assert(_.isArray(dependencies), 'dependencies should be an array');
this.dependencies = this.dependencies.concat(dependencies);
return this;
};
/**
* Mock the local configuration with the provided config
* @param {Object} localConfig - should look just like if called config.getAll()
* @return {this}
*/
RunContext.prototype.withLocalConfig = function(localConfig) {
assert(_.isObject(localConfig), 'config should be an object');
this.localConfig = localConfig;
return this;
};
module.exports = RunContext;