UNPKG

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