UNPKG

8.29 kBJavaScriptView Raw
1'use strict';
2var crypto = require('crypto');
3var path = require('path');
4var os = require('os');
5var assert = require('assert');
6var _ = require('lodash');
7var Promise = require('pinkie-promise');
8var yeoman = require('yeoman-environment');
9var util = require('util');
10var rimraf = require('rimraf');
11var EventEmitter = require('events').EventEmitter;
12var helpers = require('./');
13var TestAdapter = require('./adapter').TestAdapter;
14
15/**
16 * This class provide a run context object to façade the complexity involved in setting
17 * up a generator for testing
18 * @constructor
19 * @param {String|Function} Generator - Namespace or generator constructor. If the later
20 * is provided, then namespace is assumed to be
21 * 'gen:test' in all cases
22 * @param {Object} [settings]
23 * @param {Boolean} [settings.tmpdir=true] - Automatically run this generator in a tmp dir
24 * @param {String} [settings.resolved] - File path to the generator (only used if Generator is a constructor)
25 * @param {String} [settings.namespace='gen:test'] - Namespace (only used if Generator is a constructor)
26 * @return {this}
27 */
28
29function RunContext(Generator, settings) {
30 this._asyncHolds = 0;
31 this.ran = false;
32 this.inDirSet = false;
33 this.args = [];
34 this.options = {};
35 this.answers = {};
36 this.localConfig = null;
37 this.dependencies = [];
38 this.Generator = Generator;
39 this.settings = _.extend({ tmpdir: true, namespace: 'gen:test' }, settings);
40 this.withOptions({
41 force: true,
42 skipCache: true,
43 skipInstall: true
44 });
45 setTimeout(this._run.bind(this), 10);
46}
47util.inherits(RunContext, EventEmitter);
48
49/**
50 * Hold the execution until the returned callback is triggered
51 * @return {Function} Callback to notify the normal execution can resume
52 */
53
54RunContext.prototype.async = function() {
55 this._asyncHolds++;
56
57 return function() {
58 this._asyncHolds--;
59 this._run();
60 }.bind(this);
61};
62
63/**
64 * Method called when the context is ready to run the generator
65 * @private
66 */
67
68RunContext.prototype._run = function() {
69 if (!this.inDirSet && this.settings.tmpdir) {
70 this.inTmpDir();
71 }
72
73 if (this._asyncHolds !== 0 || this.ran) {
74 return;
75 }
76
77 this.ran = true;
78 var namespace;
79 this.env = yeoman.createEnv([], {}, new TestAdapter());
80
81 helpers.registerDependencies(this.env, this.dependencies);
82
83 if (_.isString(this.Generator)) {
84 namespace = this.env.namespace(this.Generator);
85 this.env.register(this.Generator);
86 } else {
87 namespace = this.settings.namespace;
88 this.env.registerStub(this.Generator, namespace, this.settings.resolved);
89 }
90
91 this.generator = this.env.create(namespace, {
92 arguments: this.args,
93 options: this.options
94 });
95
96 helpers.mockPrompt(this.generator, this.answers);
97
98 if (this.localConfig) {
99 // Only mock local config when withLocalConfig was called
100 helpers.mockLocalConfig(this.generator, this.localConfig);
101 }
102
103 this.generator.on(
104 'error',
105 function(err) {
106 if (!this.emit('error', err)) {
107 throw err;
108 }
109 }.bind(this)
110 );
111 this.generator.once(
112 'end',
113 function() {
114 helpers.restorePrompt(this.generator);
115 this.emit('end');
116 this.completed = true;
117 }.bind(this)
118 );
119
120 this.emit('ready', this.generator);
121 this.generator.run().catch(err => this.emit('error', err));
122};
123
124/**
125 * Return a promise representing the generator run process
126 * @return {Promise} Promise resolved on end or rejected on error
127 */
128RunContext.prototype.toPromise = function() {
129 return new Promise(
130 function(resolve, reject) {
131 this.on(
132 'end',
133 function() {
134 resolve(this.targetDirectory);
135 }.bind(this)
136 );
137 this.on('error', reject);
138 }.bind(this)
139 );
140};
141
142/**
143 * Promise `.then()` duck typing
144 * @return {Promise}
145 */
146RunContext.prototype.then = function() {
147 var promise = this.toPromise();
148 return promise.then.apply(promise, arguments);
149};
150
151/**
152 * Promise `.catch()` duck typing
153 * @return {Promise}
154 */
155RunContext.prototype.catch = function() {
156 var promise = this.toPromise();
157 return promise.catch.apply(promise, arguments);
158};
159
160/**
161 * Clean the provided directory, then change directory into it
162 * @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
163 * file path for predictable results
164 * @param {Function} [cb] - callback who'll receive the folder path as argument
165 * @return {this} run context instance
166 */
167
168RunContext.prototype.inDir = function(dirPath, cb) {
169 this.inDirSet = true;
170 this.targetDirectory = dirPath;
171 var release = this.async();
172 var callBackThenRelease = _.flowRight(
173 release,
174 (cb || _.noop).bind(this, path.resolve(dirPath))
175 );
176 helpers.testDirectory(dirPath, callBackThenRelease);
177 return this;
178};
179
180/**
181 * Change directory without deleting directory content.
182 * @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
183 * file path for predictable results
184 * @return {this} run context instance
185 */
186RunContext.prototype.cd = function(dirPath) {
187 this.inDirSet = true;
188 this.targetDirectory = dirPath;
189 dirPath = path.resolve(dirPath);
190 try {
191 process.chdir(dirPath);
192 } catch (err) {
193 throw new Error(err.message + ' ' + dirPath);
194 }
195 return this;
196};
197
198/**
199 * Cleanup a temporary directy and change the CWD into it
200 *
201 * This method is called automatically when creating a RunContext. Only use it if you need
202 * to use the callback.
203 *
204 * @param {Function} [cb] - callback who'll receive the folder path as argument
205 * @return {this} run context instance
206 */
207RunContext.prototype.inTmpDir = function(cb) {
208 var tmpdir = path.join(os.tmpdir(), crypto.randomBytes(20).toString('hex'));
209 return this.inDir(tmpdir, cb);
210};
211
212/**
213 * Clean the directory used for tests inside inDir/inTmpDir
214 */
215RunContext.prototype.cleanTestDirectory = function() {
216 if (this.targetDirectory) {
217 rimraf.sync(this.targetDirectory);
218 }
219};
220
221/**
222 * Provide arguments to the run context
223 * @param {String|Array} args - command line arguments as Array or space separated string
224 * @return {this}
225 */
226
227RunContext.prototype.withArguments = function(args) {
228 var argsArray = _.isString(args) ? args.split(' ') : args;
229 assert(
230 _.isArray(argsArray),
231 'args should be either a string separated by spaces or an array'
232 );
233 this.args = this.args.concat(argsArray);
234 return this;
235};
236
237/**
238 * Provide options to the run context
239 * @param {Object} options - command line options (e.g. `--opt-one=foo`)
240 * @return {this}
241 */
242
243RunContext.prototype.withOptions = function(options) {
244 // Add options as both kebab and camel case. This is to stay backward compatibles with
245 // the switch we made to meow for options parsing.
246 Object.keys(options).forEach(function(key) {
247 options[_.camelCase(key)] = options[key];
248 options[_.kebabCase(key)] = options[key];
249 });
250
251 this.options = _.extend(this.options, options);
252 return this;
253};
254
255/**
256 * Mock the prompt with dummy answers
257 * @param {Object} answers - Answers to the prompt questions
258 * @return {this}
259 */
260
261RunContext.prototype.withPrompts = function(answers) {
262 this.answers = _.extend(this.answers, answers);
263 return this;
264};
265
266/**
267 * Provide dependent generators
268 * @param {Array} dependencies - paths to the generators dependencies
269 * @return {this}
270 * @example
271 * var angular = new RunContext('../../app');
272 * angular.withGenerators([
273 * '../../common',
274 * '../../controller',
275 * '../../main',
276 * [helpers.createDummyGenerator(), 'testacular:app']
277 * ]);
278 * angular.on('end', function () {
279 * // assert something
280 * });
281 */
282
283RunContext.prototype.withGenerators = function(dependencies) {
284 assert(_.isArray(dependencies), 'dependencies should be an array');
285 this.dependencies = this.dependencies.concat(dependencies);
286 return this;
287};
288
289/**
290 * Mock the local configuration with the provided config
291 * @param {Object} localConfig - should look just like if called config.getAll()
292 * @return {this}
293 */
294RunContext.prototype.withLocalConfig = function(localConfig) {
295 assert(_.isObject(localConfig), 'config should be an object');
296 this.localConfig = localConfig;
297 return this;
298};
299
300module.exports = RunContext;