UNPKG

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