UNPKG

yeoman-test

Version:

Test utilities for Yeoman generators

669 lines (668 loc) 23.4 kB
import crypto from 'node:crypto'; import { existsSync, realpathSync, rmSync } from 'node:fs'; import path, { isAbsolute, join as pathJoin, resolve } from 'node:path'; import assert from 'node:assert'; import { EventEmitter } from 'node:events'; import { tmpdir } from 'node:os'; import process from 'node:process'; import { mock } from 'node:test'; import { camelCase, kebabCase, merge as lodashMerge, set as lodashSet } from 'lodash-es'; import { resetFileCommitStates } from 'mem-fs-editor/state'; import { create as createMemFs } from 'mem-fs'; import { create as createMemFsEditor } from 'mem-fs-editor'; import RunResult from './run-result.js'; import defaultHelpers from './helpers.js'; import testContext from './test-context.js'; const tempDirectory = realpathSync(tmpdir()); export class RunContextBase extends EventEmitter { mockedGenerators = {}; env; generator; settings; envOptions; completed = false; targetDirectory; editor; memFs; spawnStub; mockedGeneratorFactory; askedQuestions = []; environmentPromise; args = []; options = {}; answers; adapterOptions = {}; keepFsState; onGeneratorCallbacks = []; onTargetDirectoryCallbacks = []; onEnvironmentCallbacks = []; inDirCallbacks = []; Generator; helpers; temporaryDir = path.join(tempDirectory, crypto.randomBytes(20).toString('hex')); oldCwd; eventListenersSet = false; envCB; built = false; ran = false; errored = false; beforePrepareCallbacks = []; environmentRun; /** * This class provide a run context object to façade the complexity involved in setting * up a generator for testing * @constructor * @param Generator - Namespace or generator constructor. If the later * is provided, then namespace is assumed to be * 'gen:test' in all cases * @param settings * @return {this} */ constructor(generatorType, settings, environmentOptions = {}, helpers = defaultHelpers) { super(); this.settings = { ...settings, }; this.Generator = generatorType; this.envOptions = environmentOptions; this.oldCwd = this.settings.oldCwd; if (this.settings.cwd) { this.cd(this.settings.cwd); } this.helpers = helpers; this.memFs = settings?.memFs ?? createMemFs(); this.mockedGeneratorFactory = this.helpers.createMockedGenerator; } /** * Run the generator on the environment and promises a RunResult instance. * @return {PromiseRunResult} Promise a RunResult instance. */ async run() { this.ran = true; if (!this.built) { await this.build(); } try { const environmentRun = this.environmentRun ?? ((env, generator) => env.runGenerator(generator)); await environmentRun.call(this, this.env, this.generator); } finally { this.helpers.restorePrompt(this.env); this.completed = true; } const runResult = new RunResult(this._createRunResultOptions()); testContext.runResult = runResult; return runResult; } // If any event listeners is added, setup event listeners emitters on(eventName, listener) { super.on(eventName, listener); // Don't setup emitters if on generator envent. if (eventName !== 'generator') { this.setupEventListeners(); } return this; } /** * @deprecated * Clean the provided directory, then change directory into it * @param dirPath - Directory path (relative to CWD). Prefer passing an absolute * file path for predictable results * @param [cb] - callback who'll receive the folder path as argument * @return run context instance */ inDir(dirPath, callback) { this.setDir(dirPath, true); this.helpers.testDirectory(dirPath, () => callback?.call(this, path.resolve(dirPath))); return this; } /** * Register an callback to prepare the destination folder. * @param [cb] - callback who'll receive the folder path as argument * @return this - run context instance */ doInDir(callback) { this.inDirCallbacks.push(callback); return this; } /** * @deprecated * Change directory without deleting directory content. * @param dirPath - Directory path (relative to CWD). Prefer passing an absolute * file path for predictable results * @return run context instance */ cd(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; } /** * Cleanup 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 [cb] - callback who'll receive the folder path as argument * @return this - run context instance */ inTmpDir(callback) { return this.inDir(this.temporaryDir, callback); } /** * Restore cwd to initial cwd. * @return {this} run context instance */ restore() { 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 */ cleanup() { this.restore(); if (this.settings.tmpdir !== false) { this.cleanTestDirectory(); } } /** * Clean the directory used for tests inside inDir/inTmpDir * @param {Boolean} force - force directory cleanup for not tmpdir */ cleanupTemporaryDir() { this.restore(); if (this.temporaryDir && existsSync(this.temporaryDir)) { rmSync(this.temporaryDir, { recursive: true }); } } /** * Clean the directory used for tests inside inDir/inTmpDir * @param force - force directory cleanup for not tmpdir */ cleanTestDirectory(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 }); } } /** * TestAdapter options. */ withAdapterOptions(options) { Object.assign(this.adapterOptions, options); return this; } /** * 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 */ withEnvironment(callback) { this.envCB = callback; return this; } /** * Customize enviroment run method. * * @param callback * @return {this} run context instance */ withEnvironmentRun(callback) { this.environmentRun = callback; return this; } /** * Run lookup on the environment. * * @param lookups - lookup to run. */ withLookups(lookups) { return this.onEnvironment(async (environment) => { lookups = Array.isArray(lookups) ? lookups : [lookups]; for (const lookup of lookups) { await environment.lookup(lookup); } }); } /** * Provide arguments to the run context * @param args - command line arguments as Array or space separated string */ withArguments(arguments_) { const argumentsArray = typeof arguments_ === 'string' ? arguments_.split(' ') : arguments_; assert(Array.isArray(argumentsArray), 'args should be either a string separated by spaces or an array'); this.args = [...this.args, ...argumentsArray]; return this; } /** * Provide options to the run context * @param {Object} options - command line options (e.g. `--opt-one=foo`) * @return {this} */ withOptions(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. for (const key of Object.keys(options)) { options[camelCase(key)] = options[key]; options[kebabCase(key)] = options[key]; } this.options = { ...this.options, ...options }; return this; } /** * @deprecated * Mock the prompt with dummy answers * @param answers - Answers to the prompt questions * @param options - Options or callback. * @param {Function} [options.callback] - Callback. * @param {Boolean} [options.throwOnMissingAnswer] - Throw if a answer is missing. * @return {this} */ withPrompts(answers, options) { return this.withAnswers(answers, options); } /** * Mock answers for prompts * @param answers - Answers to the prompt questions * @param options - Options or callback. * @return {this} */ withAnswers(answers, options) { this.answers = { ...this.answers, ...answers }; Object.assign(this.adapterOptions, 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 * }); */ withGenerators(dependencies) { assert(Array.isArray(dependencies), 'dependencies should be an array'); return this.onEnvironment(async (environment) => { for (const dependency of dependencies) { if (typeof dependency === 'string') { environment.register(dependency); } else { environment.register(...dependency); } } }); } withSpawnMock(options) { if (this.spawnStub) { throw new Error('Multiple withSpawnMock calls'); } const registerNodeMockDefaults = typeof options === 'function' ? false : (options?.registerNodeMockDefaults ?? true); let implementation; if (registerNodeMockDefaults) { const defaultChild = { stdout: { on() { } }, stderr: { on() { } } }; const defaultReturn = { exitCode: 0, stdout: '', stderr: '' }; implementation = (...arguments_) => { const [methodName] = arguments_; if (methodName === 'spawnCommand' || methodName === 'spawn') { return Object.assign(Promise.resolve({ ...defaultReturn }), defaultChild); } if (methodName === 'spawnCommandSync' || methodName === 'spawnSync') { return { ...defaultReturn }; } }; } const stub = typeof options === 'function' ? options : (options?.stub ?? mock.fn(() => { }, implementation)); const callback = typeof options === 'function' ? undefined : options?.callback; if (callback) { this.onBeforePrepare(async () => { await callback({ stub, implementation }); }); } this.spawnStub = stub; return this.onEnvironment(environment => { environment.on('compose', (_namespace, generator) => { const createCallback = method => function (...arguments_) { return stub.call(this, method, ...arguments_); }; generator.spawnCommand = createCallback('spawnCommand'); generator.spawnCommandSync = createCallback('spawnCommandSync'); generator.spawn = createCallback('spawn'); generator.spawnSync = createCallback('spawnSync'); }); }); } withMockedGeneratorFactory(mockedGeneratorFactory) { this.mockedGeneratorFactory = mockedGeneratorFactory; return this; } /** * Create mocked generators * @param 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)); */ withMockedGenerators(namespaces) { assert(Array.isArray(namespaces), 'namespaces should be an array'); const mockedGenerators = Object.fromEntries(namespaces.map(namespace => [namespace, this.mockedGeneratorFactory()])); const dependencies = Object.entries(mockedGenerators).map(([namespace, mock]) => [mock, { namespace }]); Object.assign(this.mockedGenerators, mockedGenerators); return this.withGenerators(dependencies); } /** * Mock the local configuration with the provided config * @param localConfig - should look just like if called config.getAll() */ withLocalConfig(localConfig) { assert(typeof localConfig === 'object', 'config should be an object'); return this.onGenerator(generator => generator.config.defaults(localConfig)); } /** * Don't reset mem-fs state cleared to aggregate snapshots from multiple runs. */ withKeepFsState() { this.keepFsState = true; return this; } withFiles(relativePath, files) { return this.onTargetDirectory(function () { const targetDirectory = typeof relativePath === 'string' ? pathJoin(this.targetDirectory, relativePath) : this.targetDirectory; if (typeof relativePath !== 'string') { files = relativePath; } for (const [file, content] of Object.entries(files)) { const resolvedFile = isAbsolute(file) ? file : resolve(targetDirectory, file); if (typeof content === 'string') { this.editor.write(resolvedFile, content); } else { const fileContent = this.editor.readJSON(resolvedFile, {}); this.editor.writeJSON(resolvedFile, lodashMerge(fileContent, content)); } } }); } /** * Add .yo-rc.json to mem-fs. * * @param content * @returns */ withYoRc(content) { return this.withFiles({ '.yo-rc.json': content, }); } /** * Add a generator config to .yo-rc.json */ withYoRcConfig(key, content) { const yoRcContent = lodashSet({}, key, content); return this.withYoRc(yoRcContent); } /** * Commit mem-fs files. */ commitFiles() { return this.onTargetDirectory(async function () { await this.editor.commit(); }); } /** * Execute callback after targetDirectory is set * @param callback * @returns */ onTargetDirectory(callback) { this.assertNotBuild(); this.onTargetDirectoryCallbacks.push(callback); return this; } /** * Execute callback after generator is ready * @param callback * @returns */ onGenerator(callback) { this.assertNotBuild(); this.onGeneratorCallbacks.push(callback); return this; } /** * Execute callback prefore parepare * @param callback * @returns */ onBeforePrepare(callback) { this.assertNotBuild(); this.beforePrepareCallbacks.push(callback); return this; } /** * Execute callback after environment is ready * @param callback * @returns */ onEnvironment(callback) { this.assertNotBuild(); this.onEnvironmentCallbacks.push(callback); return this; } async prepare() { if (this.beforePrepareCallbacks.length > 0) { for (const callback of this.beforePrepareCallbacks) { await callback.call(this); } } this.assertNotBuild(); this.built = true; if (!this.targetDirectory && this.settings.tmpdir !== false) { this.inTmpDir(); } else if (!this.targetDirectory) { throw new Error('If not a temporary dir, pass the test cwd'); } if (this.inDirCallbacks.length > 0) { const targetDirectory = path.resolve(this.targetDirectory); for (const callback of this.inDirCallbacks) { await callback(targetDirectory); } } if (!this.targetDirectory) { throw new Error('targetDirectory is required'); } if (!this.keepFsState) { this.memFs.each(file => { resetFileCommitStates(file); }); } this.editor = createMemFsEditor(this.memFs); for (const onTargetDirectory of this.onTargetDirectoryCallbacks) { await onTargetDirectory.call(this, this.targetDirectory); } } assertNotBuild() { if (this.built || this.completed) { throw new Error('The context is already built'); } } /** * Build the generator and the environment. * @return {RunContext|false} this */ async build() { await this.prepare(); const { askedQuestions, adapterOptions } = this; const promptCallback = function (answer, options) { const { question } = options; if (question.name) { askedQuestions.push({ name: question.name, answer }); } return adapterOptions?.callback ? adapterOptions.callback.call(this, answer, options) : answer; }; const testEnvironment = await this.helpers.createTestEnv(this.envOptions.createEnv, { cwd: this.settings.forwardCwd ? this.targetDirectory : undefined, sharedFs: this.memFs, force: true, skipCache: true, skipInstall: true, adapter: this.helpers.createTestAdapter({ ...this.adapterOptions, mockedAnswers: this.answers, callback: promptCallback }), ...this.envOptions, }); this.env = this.envCB ? ((await this.envCB(testEnvironment)) ?? testEnvironment) : testEnvironment; for (const onEnvironmentCallback of this.onEnvironmentCallbacks) { await onEnvironmentCallback.call(this, this.env); } const { namespace = typeof this.Generator === 'string' ? this.env.namespace(this.Generator) : 'gen:test' } = this.settings; if (typeof this.Generator === 'string' && namespace !== this.Generator) { // Generator is a file path, it should be registered. this.env.register(this.Generator, { namespace }); } else if (typeof this.Generator !== 'string') { const { resolved } = this.settings; this.env.register(this.Generator, { namespace, resolved }); } this.generator = await this.env.create(namespace, { generatorArgs: this.args, generatorOptions: { force: true, skipCache: true, skipInstall: true, ...this.options, }, }); for (const onGeneratorCallback of this.onGeneratorCallbacks) { await onGeneratorCallback.call(this, this.generator); } } /** * Return a promise representing the generator run process * @return Promise resolved on end or rejected on error */ async toPromise() { return this.environmentPromise ?? this.run(); } _createRunResultOptions() { return { env: this.env, generator: this.generator, memFs: this.env?.sharedFs ?? this.memFs, settings: { ...this.settings, }, spawnStub: this.spawnStub, oldCwd: this.oldCwd, cwd: this.targetDirectory, envOptions: this.envOptions, mockedGenerators: this.mockedGenerators, helpers: this.helpers, askedQuestions: this.askedQuestions, }; } /** * Keeps compatibility with events */ setupEventListeners() { if (this.eventListenersSet) { return undefined; } this.eventListenersSet = true; this.onGenerator(generator => this.emit('ready', generator)); this.onGenerator(generator => this.emit('generator', generator)); return this.build().then(async () => this.run() .catch(error => { if (this.listenerCount('end') === 0 && this.listenerCount('error') === 0) { // When there is no listeners throw a unhandled rejection. setImmediate(async () => { throw error; }); } else { this.errored = true; this.emit('error', error); } }) .finally(() => { this.emit('end'); })); } /** * 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 */ setDir(dirPath, tmpdir) { if (this.targetDirectory) { 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.targetDirectory = dirPath; return this; } } export default class RunContext extends RunContextBase { async then(onfulfilled, onrejected) { return this.toPromise().then(onfulfilled, onrejected); } async catch(onrejected) { return this.toPromise().catch(onrejected); } async finally(onfinally) { return this.toPromise().finally(onfinally); } get [Symbol.toStringTag]() { return `RunContext`; } } export class BasicRunContext extends RunContext { async run() { await this.prepare(); const runResult = new RunResult(this._createRunResultOptions()); testContext.runResult = runResult; return runResult; } }