yeoman-test 6.2.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ import crypto from 'node:crypto';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import path, { resolve, isAbsolute, join as pathJoin } from 'node:path';
4
+ import assert from 'node:assert';
5
+ import { EventEmitter } from 'node:events';
6
+ import process from 'node:process';
7
+ import _ from 'lodash';
8
+ import tempDirectory from 'temp-dir';
9
+ import MemFsEditor from 'mem-fs-editor';
10
+ import RunResult from './run-result.js';
11
+ import defaultHelpers from './helpers.js';
12
+ export class RunContextBase extends EventEmitter {
13
+ mockedGenerators = {};
14
+ env;
15
+ generator;
16
+ settings;
17
+ envOptions;
18
+ completed = false;
19
+ targetDirectory;
20
+ editor;
21
+ environmentPromise;
22
+ args = [];
23
+ options = {};
24
+ answers;
25
+ onGeneratorCallbacks = [];
26
+ onTargetDirectoryCallbacks = [];
27
+ onEnvironmentCallbacks = [];
28
+ inDirCallbacks = [];
29
+ Generator;
30
+ helpers;
31
+ temporaryDir = path.join(tempDirectory, crypto.randomBytes(20).toString('hex'));
32
+ oldCwd;
33
+ eventListenersSet = false;
34
+ envCB;
35
+ ran = false;
36
+ errored = false;
37
+ /**
38
+ * This class provide a run context object to façade the complexity involved in setting
39
+ * up a generator for testing
40
+ * @constructor
41
+ * @param Generator - Namespace or generator constructor. If the later
42
+ * is provided, then namespace is assumed to be
43
+ * 'gen:test' in all cases
44
+ * @param settings
45
+ * @return {this}
46
+ */
47
+ constructor(generatorType, settings, envOptions = {}, helpers = defaultHelpers) {
48
+ super();
49
+ this.settings = {
50
+ namespace: 'gen:test',
51
+ ...settings,
52
+ };
53
+ this.Generator = generatorType;
54
+ if (typeof generatorType !== 'string') {
55
+ const { namespace, resolved } = this.settings;
56
+ this.withGenerators([[generatorType, namespace, resolved]]);
57
+ }
58
+ this.envOptions = envOptions;
59
+ this.withOptions({
60
+ force: true,
61
+ skipCache: true,
62
+ skipInstall: true,
63
+ });
64
+ this.oldCwd = this.settings.oldCwd;
65
+ if (this.settings.cwd) {
66
+ this.cd(this.settings.cwd);
67
+ }
68
+ this.helpers = helpers;
69
+ }
70
+ /**
71
+ * Run the generator on the environment and promises a RunResult instance.
72
+ * @return {PromiseRunResult} Promise a RunResult instance.
73
+ */
74
+ async run() {
75
+ if (!this.ran) {
76
+ await this.build();
77
+ }
78
+ try {
79
+ await this.env.runGenerator(this.generator);
80
+ }
81
+ finally {
82
+ this.helpers.restorePrompt(this.env);
83
+ this.completed = true;
84
+ }
85
+ return new RunResult(this._createRunResultOptions());
86
+ }
87
+ // If any event listeners is added, setup event listeners emitters
88
+ on(eventName, listener) {
89
+ super.on(eventName, listener);
90
+ // Don't setup emitters if on generator envent.
91
+ if (eventName !== 'generator') {
92
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
93
+ this.setupEventListeners();
94
+ }
95
+ return this;
96
+ }
97
+ /**
98
+ * @deprecated
99
+ * Clean the provided directory, then change directory into it
100
+ * @param dirPath - Directory path (relative to CWD). Prefer passing an absolute
101
+ * file path for predictable results
102
+ * @param [cb] - callback who'll receive the folder path as argument
103
+ * @return run context instance
104
+ */
105
+ inDir(dirPath, cb) {
106
+ this.setDir(dirPath, true);
107
+ this.helpers.testDirectory(dirPath, () => cb?.call(this, path.resolve(dirPath)));
108
+ return this;
109
+ }
110
+ /**
111
+ * Register an callback to prepare the destination folder.
112
+ * @param [cb] - callback who'll receive the folder path as argument
113
+ * @return this - run context instance
114
+ */
115
+ doInDir(cb) {
116
+ this.inDirCallbacks.push(cb);
117
+ return this;
118
+ }
119
+ /**
120
+ * @deprecated
121
+ * Change directory without deleting directory content.
122
+ * @param dirPath - Directory path (relative to CWD). Prefer passing an absolute
123
+ * file path for predictable results
124
+ * @return run context instance
125
+ */
126
+ cd(dirPath) {
127
+ dirPath = path.resolve(dirPath);
128
+ this.setDir(dirPath, false);
129
+ try {
130
+ process.chdir(dirPath);
131
+ }
132
+ catch (error) {
133
+ this.completed = true;
134
+ throw new Error(`${error.message} ${dirPath}`);
135
+ }
136
+ return this;
137
+ }
138
+ /**
139
+ * Cleanup a temporary directory and change the CWD into it
140
+ *
141
+ * This method is called automatically when creating a RunContext. Only use it if you need
142
+ * to use the callback.
143
+ *
144
+ * @param [cb] - callback who'll receive the folder path as argument
145
+ * @return this - run context instance
146
+ */
147
+ inTmpDir(cb) {
148
+ return this.inDir(this.temporaryDir, cb);
149
+ }
150
+ /**
151
+ * Restore cwd to initial cwd.
152
+ * @return {this} run context instance
153
+ */
154
+ restore() {
155
+ if (this.oldCwd) {
156
+ process.chdir(this.oldCwd);
157
+ }
158
+ return this;
159
+ }
160
+ /**
161
+ * Clean the directory used for tests inside inDir/inTmpDir
162
+ * @param {Boolean} force - force directory cleanup for not tmpdir
163
+ */
164
+ cleanup() {
165
+ this.restore();
166
+ if (this.settings.tmpdir !== false) {
167
+ this.cleanTestDirectory();
168
+ }
169
+ }
170
+ /**
171
+ * Clean the directory used for tests inside inDir/inTmpDir
172
+ * @param {Boolean} force - force directory cleanup for not tmpdir
173
+ */
174
+ cleanupTemporaryDir() {
175
+ this.restore();
176
+ if (this.temporaryDir && existsSync(this.temporaryDir)) {
177
+ rmSync(this.temporaryDir, { recursive: true });
178
+ }
179
+ }
180
+ /**
181
+ * Clean the directory used for tests inside inDir/inTmpDir
182
+ * @param force - force directory cleanup for not tmpdir
183
+ */
184
+ cleanTestDirectory(force = false) {
185
+ if (!force && this.settings.tmpdir === false) {
186
+ throw new Error('Cleanup test dir called with false tmpdir option.');
187
+ }
188
+ if (this.targetDirectory && existsSync(this.targetDirectory)) {
189
+ rmSync(this.targetDirectory, { recursive: true });
190
+ }
191
+ }
192
+ /**
193
+ * Create an environment
194
+ *
195
+ * This method is called automatically when creating a RunContext. Only use it if you need
196
+ * to use the callback.
197
+ *
198
+ * @param {Function} [cb] - callback who'll receive the folder path as argument
199
+ * @return {this} run context instance
200
+ */
201
+ withEnvironment(cb) {
202
+ this.envCB = cb;
203
+ return this;
204
+ }
205
+ /**
206
+ * Run lookup on the environment.
207
+ *
208
+ * @param lookups - lookup to run.
209
+ */
210
+ withLookups(lookups) {
211
+ return this.onEnvironment(env => {
212
+ lookups = Array.isArray(lookups) ? lookups : [lookups];
213
+ for (const lookup of lookups) {
214
+ env.lookup(lookup);
215
+ }
216
+ });
217
+ }
218
+ /**
219
+ * Provide arguments to the run context
220
+ * @param args - command line arguments as Array or space separated string
221
+ */
222
+ withArguments(args) {
223
+ const argsArray = typeof args === 'string' ? args.split(' ') : args;
224
+ assert(Array.isArray(argsArray), 'args should be either a string separated by spaces or an array');
225
+ this.args = this.args.concat(argsArray);
226
+ return this;
227
+ }
228
+ /**
229
+ * Provide options to the run context
230
+ * @param {Object} options - command line options (e.g. `--opt-one=foo`)
231
+ * @return {this}
232
+ */
233
+ withOptions(options) {
234
+ if (!options) {
235
+ return this;
236
+ }
237
+ // Add options as both kebab and camel case. This is to stay backward compatibles with
238
+ // the switch we made to meow for options parsing.
239
+ for (const key of Object.keys(options)) {
240
+ options[_.camelCase(key)] = options[key];
241
+ options[_.kebabCase(key)] = options[key];
242
+ }
243
+ this.options = { ...this.options, ...options };
244
+ return this;
245
+ }
246
+ /**
247
+ * @deprecated
248
+ * Mock the prompt with dummy answers
249
+ * @param answers - Answers to the prompt questions
250
+ * @param options - Options or callback.
251
+ * @param {Function} [options.callback] - Callback.
252
+ * @param {Boolean} [options.throwOnMissingAnswer] - Throw if a answer is missing.
253
+ * @return {this}
254
+ */
255
+ withPrompts(answers, options) {
256
+ return this.withAnswers(answers, options);
257
+ }
258
+ /**
259
+ * Mock answers for prompts
260
+ * @param answers - Answers to the prompt questions
261
+ * @param options - Options or callback.
262
+ * @return {this}
263
+ */
264
+ withAnswers(answers, options) {
265
+ const callbackSet = Boolean(this.answers);
266
+ this.answers = { ...this.answers, ...answers };
267
+ if (callbackSet)
268
+ return this;
269
+ return this.onEnvironment(env => {
270
+ this.helpers.mockPrompt(env, this.answers, options);
271
+ });
272
+ }
273
+ /**
274
+ * Provide dependent generators
275
+ * @param {Array} dependencies - paths to the generators dependencies
276
+ * @return {this}
277
+ * @example
278
+ * var angular = new RunContext('../../app');
279
+ * angular.withGenerators([
280
+ * '../../common',
281
+ * '../../controller',
282
+ * '../../main',
283
+ * [helpers.createDummyGenerator(), 'testacular:app']
284
+ * ]);
285
+ * angular.on('end', function () {
286
+ * // assert something
287
+ * });
288
+ */
289
+ withGenerators(dependencies) {
290
+ assert(Array.isArray(dependencies), 'dependencies should be an array');
291
+ return this.onEnvironment(env => {
292
+ for (const dependency of dependencies) {
293
+ if (Array.isArray(dependency)) {
294
+ env.registerStub(...dependency);
295
+ }
296
+ else {
297
+ env.register(dependency);
298
+ }
299
+ }
300
+ });
301
+ }
302
+ /**
303
+ * Create mocked generators
304
+ * @param namespaces - namespaces of mocked generators
305
+ * @return this
306
+ * @example
307
+ * var angular = helpers
308
+ * .create('../../app')
309
+ * .withMockedGenerators([
310
+ * 'foo:app',
311
+ * 'foo:bar',
312
+ * ])
313
+ * .run()
314
+ * .then(runResult => assert(runResult
315
+ * .mockedGenerators['foo:app']
316
+ .calledOnce));
317
+ */
318
+ withMockedGenerators(namespaces) {
319
+ assert(Array.isArray(namespaces), 'namespaces should be an array');
320
+ const dependencies = namespaces.map(namespace => [this.helpers.createMockedGenerator(), namespace]);
321
+ const entries = dependencies.map(([generator, namespace]) => [namespace, generator]);
322
+ Object.assign(this.mockedGenerators, Object.fromEntries(entries));
323
+ return this.withGenerators(dependencies);
324
+ }
325
+ /**
326
+ * Mock the local configuration with the provided config
327
+ * @param localConfig - should look just like if called config.getAll()
328
+ */
329
+ withLocalConfig(localConfig) {
330
+ assert(typeof localConfig === 'object', 'config should be an object');
331
+ return this.onGenerator(generator => generator.config.defaults(localConfig));
332
+ }
333
+ withFiles(relativePath, files) {
334
+ return this.onTargetDirectory(function () {
335
+ const targetDirectory = typeof relativePath === 'string' ? pathJoin(this.targetDirectory, relativePath) : this.targetDirectory;
336
+ if (typeof relativePath !== 'string') {
337
+ files = relativePath;
338
+ }
339
+ for (const [file, content] of Object.entries(files)) {
340
+ const resolvedFile = isAbsolute(file) ? file : resolve(targetDirectory, file);
341
+ if (typeof content === 'string') {
342
+ this.editor.write(resolvedFile, content);
343
+ }
344
+ else {
345
+ this.editor.writeJSON(resolvedFile, content);
346
+ }
347
+ }
348
+ });
349
+ }
350
+ /**
351
+ * Add .yo-rc.json to mem-fs.
352
+ *
353
+ * @param content
354
+ * @returns
355
+ */
356
+ withYoRc(content) {
357
+ return this.withFiles({
358
+ '.yo-rc.json': content,
359
+ });
360
+ }
361
+ /**
362
+ * Commit mem-fs files.
363
+ */
364
+ commitFiles() {
365
+ return this.onTargetDirectory(async function () {
366
+ await this.editor.commit();
367
+ });
368
+ }
369
+ /**
370
+ * Execute callback after targetDirectory is set
371
+ * @param callback
372
+ * @returns
373
+ */
374
+ onTargetDirectory(callback) {
375
+ this.assertNotBuild();
376
+ this.onTargetDirectoryCallbacks.push(callback);
377
+ return this;
378
+ }
379
+ /**
380
+ * Execute callback after generator is ready
381
+ * @param callback
382
+ * @returns
383
+ */
384
+ onGenerator(callback) {
385
+ this.assertNotBuild();
386
+ this.onGeneratorCallbacks.push(callback);
387
+ return this;
388
+ }
389
+ /**
390
+ * Execute callback after environment is ready
391
+ * @param callback
392
+ * @returns
393
+ */
394
+ onEnvironment(callback) {
395
+ this.assertNotBuild();
396
+ this.onEnvironmentCallbacks.push(callback);
397
+ return this;
398
+ }
399
+ assertNotBuild() {
400
+ if (this.ran || this.completed) {
401
+ throw new Error('The context is already built');
402
+ }
403
+ }
404
+ /**
405
+ * Build the generator and the environment.
406
+ * @return {RunContext|false} this
407
+ */
408
+ async build() {
409
+ this.assertNotBuild();
410
+ this.ran = true;
411
+ if (!this.targetDirectory && this.settings.tmpdir !== false) {
412
+ this.inTmpDir();
413
+ }
414
+ else if (!this.targetDirectory) {
415
+ throw new Error('If not a temporary dir, pass the test cwd');
416
+ }
417
+ if (this.inDirCallbacks.length > 0) {
418
+ const targetDirectory = path.resolve(this.targetDirectory);
419
+ for (const cb of this.inDirCallbacks) {
420
+ // eslint-disable-next-line no-await-in-loop
421
+ await cb(targetDirectory);
422
+ }
423
+ }
424
+ if (!this.targetDirectory) {
425
+ throw new Error('targetDirectory is required');
426
+ }
427
+ const testEnv = this.helpers.createTestEnv(this.envOptions.createEnv, {
428
+ cwd: this.settings.forwardCwd ? this.targetDirectory : undefined,
429
+ ...this.options,
430
+ ...this.envOptions,
431
+ });
432
+ this.env = this.envCB ? (await this.envCB(testEnv)) ?? testEnv : testEnv;
433
+ this.editor = MemFsEditor.create(this.env.sharedFs);
434
+ for (const onTargetDirectory of this.onTargetDirectoryCallbacks) {
435
+ // eslint-disable-next-line no-await-in-loop
436
+ await onTargetDirectory.call(this, this.targetDirectory);
437
+ }
438
+ for (const onEnvironmentCallback of this.onEnvironmentCallbacks) {
439
+ // eslint-disable-next-line no-await-in-loop
440
+ await onEnvironmentCallback.call(this, this.env);
441
+ }
442
+ let { namespace } = this.settings;
443
+ if (typeof this.Generator === 'string') {
444
+ namespace = this.env.namespace(this.Generator);
445
+ if (namespace !== this.Generator) {
446
+ // Generator is a file path, it should be registered.
447
+ this.env.register(this.Generator);
448
+ }
449
+ }
450
+ // eslint-disable-next-line @typescript-eslint/await-thenable
451
+ this.generator = (await this.env.create(namespace, this.args, this.options));
452
+ for (const onGeneratorCallback of this.onGeneratorCallbacks) {
453
+ // eslint-disable-next-line no-await-in-loop
454
+ await onGeneratorCallback.call(this, this.generator);
455
+ }
456
+ }
457
+ /**
458
+ * Return a promise representing the generator run process
459
+ * @return Promise resolved on end or rejected on error
460
+ */
461
+ async toPromise() {
462
+ return this.environmentPromise ?? this.run();
463
+ }
464
+ /**
465
+ * Keeps compatibility with events
466
+ */
467
+ setupEventListeners() {
468
+ if (this.eventListenersSet) {
469
+ return undefined;
470
+ }
471
+ this.eventListenersSet = true;
472
+ this.onGenerator(generator => this.emit('ready', generator));
473
+ this.onGenerator(generator => this.emit('generator', generator));
474
+ return this.build().then(async () => this.run()
475
+ .catch(error => {
476
+ if (this.listenerCount('end') === 0 && this.listenerCount('error') === 0) {
477
+ // When there is no listeners throw a unhandled rejection.
478
+ setImmediate(async function () {
479
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
480
+ throw error;
481
+ });
482
+ }
483
+ else {
484
+ this.errored = true;
485
+ this.emit('error', error);
486
+ }
487
+ })
488
+ .finally(() => {
489
+ this.emit('end');
490
+ }));
491
+ }
492
+ /**
493
+ * Set the target directory.
494
+ * @private
495
+ * @param {String} dirPath - Directory path (relative to CWD). Prefer passing an absolute
496
+ * file path for predictable results
497
+ * @return {this} run context instance
498
+ */
499
+ setDir(dirPath, tmpdir) {
500
+ if (this.targetDirectory) {
501
+ this.completed = true;
502
+ throw new Error('Test directory has already been set.');
503
+ }
504
+ if (tmpdir !== undefined) {
505
+ this.settings.tmpdir = tmpdir;
506
+ }
507
+ this.oldCwd = this.oldCwd ?? process.cwd();
508
+ this.targetDirectory = dirPath;
509
+ return this;
510
+ }
511
+ _createRunResultOptions() {
512
+ return {
513
+ env: this.env,
514
+ generator: this.generator,
515
+ memFs: this.env.sharedFs,
516
+ settings: {
517
+ ...this.settings,
518
+ },
519
+ oldCwd: this.oldCwd,
520
+ cwd: this.targetDirectory,
521
+ envOptions: this.envOptions,
522
+ mockedGenerators: this.mockedGenerators,
523
+ helpers: this.helpers,
524
+ };
525
+ }
526
+ }
527
+ export default class RunContext extends RunContextBase {
528
+ // eslint-disable-next-line unicorn/no-thenable
529
+ async then(onfulfilled, onrejected) {
530
+ return this.toPromise().then(onfulfilled, onrejected);
531
+ }
532
+ async catch(onrejected) {
533
+ return this.toPromise().catch(onrejected);
534
+ }
535
+ async finally(onfinally) {
536
+ return this.toPromise().finally(onfinally);
537
+ }
538
+ get [Symbol.toStringTag]() {
539
+ return `RunContext`;
540
+ }
541
+ }