yeoman-test
Version:
Test utilities for Yeoman generators
669 lines (668 loc) • 23.4 kB
JavaScript
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;
}
}