'use strict';
const crypto = require('crypto');
const {existsSync, rmdirSync, rmSync = rmdirSync} = require('fs');
const path = require('path');
const assert = require('assert');
const _ = require('lodash/string');
const util = require('util');
const {EventEmitter} = require('events');
const tempDirectory = require('temp-dir');
const RunResult = require('./run-result');
/**
* 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] - 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 = {},
helpers = require('.')
) {
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.mockedGenerators = {};
this.settings = {
namespace: 'gen:test',
runEnvironment: false,
...settings
};
this.envOptions = envOptions;
this.withOptions({
force: true,
skipCache: true,
skipInstall: true
});
this.oldCwd = this.settings.oldCwd;
if (this.settings.cwd) {
this.cd(this.settings.cwd);
}
if (!this.settings.runEnvironment) {
setTimeout(this._run.bind(this), 10);
}
this.helpers = helpers;
}
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);
};
/**
* Build the generator and the environment.
* @return {RunContext|false} this
*/
RunContext.prototype.build = function (callback = () => {}) {
if (!this.inDirSet && this.settings.tmpdir !== false) {
this.inTmpDir();
}
if (this._asyncHolds !== 0 || this.ran || this.completed) {
if (this.buildAsync) {
return false;
}
throw new Error('The context is not ready');
}
this.ran = true;
if (this.inDirCallbacks.length > 0) {
const targetDirectory = path.resolve(this.targetDirectory);
this.inDirCallbacks.forEach((cb) => cb(targetDirectory));
}
this.targetDirectory = this.targetDirectory || process.cwd();
const testEnv = this.helpers.createTestEnv(this.envOptions.createEnv, {
cwd: this.settings.forwardCwd ? this.targetDirectory : undefined,
...this.options,
...this.envOptions
});
this.env = this.envCB ? this.envCB(testEnv) || testEnv : testEnv;
this.lookups.forEach((lookup) => {
this.env.lookup(lookup);
});
this.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._generatorPromise = Promise.resolve(
this.env.create(namespace, {
arguments: this.args,
options: this.options
})
);
this._generatorPromise.then((generator) => {
this.generator = generator;
});
this.helpers.mockPrompt(this.env, this.answers, this.promptOptions);
if (this.localConfig) {
// Only mock local config when withLocalConfig was called
this._generatorPromise = this._generatorPromise.then((generator) => {
this.helpers.mockLocalConfig(generator, this.localConfig);
return generator;
});
}
callback(this);
return this;
};
/**
* Method called when the context is ready to run the generator
* @private
*/
RunContext.prototype._run = function () {
this.buildAsync = true;
if (this.build() === false) return false;
this._generatorPromise.then((generator) => this.emit('ready', generator));
this.run()
.catch((error) => {
if (
this.listenerCount('end') === 0 &&
this.listenerCount('error') === 0
) {
// When there is no listeners throw a unhandled rejection.
setImmediate(function () {
return Promise.reject(error);
});
} else {
this.errored = true;
this.emit('error', error);
}
})
.finally(() => {
this.emit('end');
this.completed = true;
});
return true;
};
/**
* Run the generator on the environment and promises a RunResult instance.
* @return {Promise<RunResult>} Promise a RunResult instance.
*/
RunContext.prototype.run = function () {
if (!this.settings.runEnvironment && this.buildAsync === undefined) {
throw new Error('Should be called with runEnvironment option');
}
if (!this.ran) {
this.build();
}
return this._generatorPromise.then((generator) =>
this.env
.runGenerator(generator)
.then(() => new RunResult(this._createRunResultOptions()))
.finally(() => {
this.helpers.restorePrompt(this.env);
})
);
};
RunContext.prototype._createRunResultOptions = function () {
return {
env: this.env,
generator: this.generator,
memFs: this.env.sharedFs,
settings: {
...this.settings
},
oldCwd: this.oldCwd,
cwd: this.targetDirectory,
envOptions: this.envOptions,
mockedGenerators: this.mockedGenerators
};
};
/**
* 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(new RunResult(this._createRunResultOptions()));
}.bind(this)
);
this.on('error', reject);
}.bind(this)
);
};
/**
* Promise `.then()` duck typing
* @return {Promise}
*/
RunContext.prototype.then = function () {
const promise = this.toPromise();
return promise.then(...arguments);
};
/**
* Promise `.catch()` duck typing
* @return {Promise}
*/
RunContext.prototype.catch = function () {
const promise = this.toPromise();
return promise.catch(...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, tmpdir) {
if (this.inDirSet) {
this.completed = true;
throw new Error('Test directory has already been set.');
}
if (tmpdir !== undefined) {
this.settings.tmpdir = tmpdir;
}
this.oldCwd = this.oldCwd || process.cwd();
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, true);
this.helpers.testDirectory(
dirPath,
(cb || (() => {})).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) {
dirPath = path.resolve(dirPath);
this.setDir(dirPath, false);
try {
process.chdir(dirPath);
} catch (error) {
this.completed = true;
throw new Error(error.message + ' ' + dirPath);
}
return this;
};
/**
* Creates a temporary directory 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) {
return this.inDir(
path.join(tempDirectory, crypto.randomBytes(20).toString('hex')),
cb
);
};
/**
* Restore cwd to initial cwd.
* @return {this} run context instance
*/
RunContext.prototype.restore = function () {
if (this.oldCwd) {
process.chdir(this.oldCwd);
}
return this;
};
/**
* Clean the directory used for tests inside inDir/inTmpDir
* @param {Boolean} force - force directory cleanup for not tmpdir
*/
RunContext.prototype.cleanup = function () {
this.restore();
if (this.settings.tmpdir !== false) {
this.cleanTestDirectory();
}
};
/**
* 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
* @param {Boolean} force - force directory cleanup for not tmpdir
*/
RunContext.prototype.cleanTestDirectory = function (force = false) {
if (!force && this.settings.tmpdir === false) {
throw new Error('Cleanup test dir called with false tmpdir option.');
}
if (this.targetDirectory && existsSync(this.targetDirectory)) {
rmSync(this.targetDirectory, {recursive: true});
}
};
/**
* 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) {
const argsArray = typeof args === 'string' ? args.split(' ') : args;
assert(
Array.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) {
if (!options) {
return this;
}
// 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 = {...this.options, ...options};
return this;
};
/**
* Mock the prompt with dummy answers
* @param {Object} answers - Answers to the prompt questions
* @param {Object|Function} [options] - Options or callback.
* @param {Function} [options.callback] - Callback.
* @param {Boolean} [options.throwOnMissingAnswer] - Throw if a answer is missing.
* @return {this}
*/
RunContext.prototype.withPrompts = function (answers, options) {
this.answers = {...this.answers, ...answers};
this.promptOptions = options;
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(Array.isArray(dependencies), 'dependencies should be an array');
this.dependencies = this.dependencies.concat(dependencies);
return this;
};
/**
* Create mocked generators
* @param {Array} namespaces - namespaces of mocked generators
* @return {this}
* @example
* var angular = helpers
* .create('../../app')
* .withMockedGenerators([
* 'foo:app',
* 'foo:bar',
* ])
* .run()
* .then(runResult => assert(runResult
* .mockedGenerators['foo:app']
.calledOnce));
*/
RunContext.prototype.withMockedGenerators = function (namespaces) {
assert(Array.isArray(namespaces), 'namespaces should be an array');
const entries = namespaces.map((namespace) => [
namespace,
this.helpers.createMockedGenerator()
]);
this.mockedGenerators = {
...this.mockedGenerators,
...Object.fromEntries(entries)
};
const dependencies = entries.map(([namespace, generator]) => [
generator,
namespace
]);
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(typeof localConfig === 'object', 'config should be an object');
this.localConfig = localConfig;
return this;
};
module.exports = RunContext;