refactor: make the cli testable

This commit is contained in:
Alan Agius 2018-09-14 09:34:53 +02:00 committed by Hans
parent 1753b6affb
commit 40129ccebd

View File

@ -17,7 +17,7 @@ import {
terminal, terminal,
virtualFs, virtualFs,
} from '@angular-devkit/core'; } from '@angular-devkit/core';
import { NodeJsSyncHost, createConsoleLogger } from '@angular-devkit/core/node'; import { NodeJsSyncHost, ProcessOutput, createConsoleLogger } from '@angular-devkit/core/node';
import { import {
DryRunEvent, DryRunEvent,
SchematicEngine, SchematicEngine,
@ -31,42 +31,6 @@ import {
import * as minimist from 'minimist'; import * as minimist from 'minimist';
/**
* Show usage of the CLI tool, and exit the process.
*/
function usage(exitCode = 0): never {
logger.info(tags.stripIndent`
schematics [CollectionName:]SchematicName [options, ...]
By default, if the collection name is not specified, use the internal collection provided
by the Schematics CLI.
Options:
--debug Debug mode. This is true by default if the collection is a relative
path (in that case, turn off with --debug=false).
--allowPrivate Allow private schematics to be run from the command line. Default to
false.
--dry-run Do not output anything, but instead just show what actions would be
performed. Default to true if debug is also true.
--force Force overwriting files that would otherwise be an error.
--list-schematics List all schematics from the collection, by name. A collection name
should be suffixed by a colon. Example: '@schematics/schematics:'.
--verbose Show more information.
--help Show this message.
Any additional option is passed to the Schematics depending on
`);
return process.exit(exitCode);
}
/** /**
* Parse the name of schematic passed in argument, and return a {collection, schematic} named * Parse the name of schematic passed in argument, and return a {collection, schematic} named
* tuple. The user can pass in `collection-name:schematic-name`, and this function will either * tuple. The user can pass in `collection-name:schematic-name`, and this function will either
@ -81,7 +45,7 @@ function usage(exitCode = 0): never {
* @param str The argument to parse. * @param str The argument to parse.
* @return {{collection: string, schematic: (string)}} * @return {{collection: string, schematic: (string)}}
*/ */
function parseSchematicName(str: string | null): { collection: string, schematic: string | null} { function parseSchematicName(str: string | null): { collection: string, schematic: string | null } {
let collection = '@schematics/schematics'; let collection = '@schematics/schematics';
let schematic = str; let schematic = str;
@ -93,182 +57,204 @@ function parseSchematicName(str: string | null): { collection: string, schematic
} }
/** Parse the command line. */ export interface MainOptions {
const booleanArgs = [ args: string[];
'allowPrivate', stdout?: ProcessOutput;
'debug', stderr?: ProcessOutput;
'dry-run',
'force',
'help',
'list-schematics',
'verbose',
];
const argv = minimist(process.argv.slice(2), {
boolean: booleanArgs,
default: {
'debug': null,
'dry-run': null,
},
'--': true,
});
/** Create the DevKit Logger used through the CLI. */
const logger = createConsoleLogger(argv['verbose']);
if (argv.help) {
usage();
} }
/** Get the collection an schematic name from the first argument. */ export async function main({
const { args,
collection: collectionName, stdout = process.stdout,
schematic: schematicName, stderr = process.stderr,
} = parseSchematicName(argv._.shift() || null); }: MainOptions): Promise<0 | 1> {
const isLocalCollection = collectionName.startsWith('.') || collectionName.startsWith('/');
/** Parse the command line. */
const booleanArgs = [
'allowPrivate',
'debug',
'dry-run',
'force',
'help',
'list-schematics',
'verbose',
];
const argv = minimist(args, {
boolean: booleanArgs,
default: {
'debug': null,
'dry-run': null,
},
'--': true,
});
/** Create the DevKit Logger used through the CLI. */
const logger = createConsoleLogger(argv['verbose'], stdout, stderr);
if (argv.help) {
logger.info(getUsage());
return 0;
}
/** Get the collection an schematic name from the first argument. */
const {
collection: collectionName,
schematic: schematicName,
} = parseSchematicName(argv._.shift() || null);
const isLocalCollection = collectionName.startsWith('.') || collectionName.startsWith('/');
/** If the user wants to list schematics, we simply show all the schematic names. */ /** If the user wants to list schematics, we simply show all the schematic names. */
if (argv['list-schematics']) { if (argv['list-schematics']) {
const engineHost = isLocalCollection const engineHost = isLocalCollection
? new FileSystemEngineHost(normalize(process.cwd())) ? new FileSystemEngineHost(normalize(process.cwd()))
: new NodeModulesEngineHost(); : new NodeModulesEngineHost();
const engine = new SchematicEngine(engineHost); const engine = new SchematicEngine(engineHost);
const collection = engine.createCollection(collectionName); const collection = engine.createCollection(collectionName);
logger.info(engine.listSchematicNames(collection).join('\n')); logger.info(engine.listSchematicNames(collection).join('\n'));
process.exit(0); return 0;
} }
if (!schematicName) { if (!schematicName) {
usage(1); logger.info(getUsage());
throw 0; // TypeScript doesn't know that process.exit() never returns.
}
/** Gather the arguments for later use. */ return 1;
const debug: boolean = argv.debug === null ? isLocalCollection : argv.debug; }
const dryRun: boolean = argv['dry-run'] === null ? debug : argv['dry-run'];
const force = argv['force'];
const allowPrivate = argv['allowPrivate'];
/** Create a Virtual FS Host scoped to where the process is being run. **/ /** Gather the arguments for later use. */
const fsHost = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(process.cwd())); const debug: boolean = argv.debug === null ? isLocalCollection : argv.debug;
const dryRun: boolean = argv['dry-run'] === null ? debug : argv['dry-run'];
const force = argv['force'];
const allowPrivate = argv['allowPrivate'];
/** Create the workflow that will be executed with this run. */ /** Create a Virtual FS Host scoped to where the process is being run. **/
const workflow = new NodeWorkflow(fsHost, { force, dryRun }); const fsHost = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(process.cwd()));
// Indicate to the user when nothing has been done. This is automatically set to off when there's /** Create the workflow that will be executed with this run. */
// a new DryRunEvent. const workflow = new NodeWorkflow(fsHost, { force, dryRun });
let nothingDone = true;
// Logging queue that receives all the messages to show the users. This only get shown when no // Indicate to the user when nothing has been done. This is automatically set to off when there's
// errors happened. // a new DryRunEvent.
let loggingQueue: string[] = []; let nothingDone = true;
let error = false;
/** // Logging queue that receives all the messages to show the users. This only get shown when no
* Logs out dry run events. // errors happened.
* let loggingQueue: string[] = [];
* All events will always be executed here, in order of discovery. That means that an error would let error = false;
* be shown along other events when it happens. Since errors in workflows will stop the Observable
* from completing successfully, we record any events other than errors, then on completion we
* show them.
*
* This is a simple way to only show errors when an error occur.
*/
workflow.reporter.subscribe((event: DryRunEvent) => {
nothingDone = false;
switch (event.kind) { /**
case 'error': * Logs out dry run events.
error = true; *
* All events will always be executed here, in order of discovery. That means that an error would
* be shown along other events when it happens. Since errors in workflows will stop the Observable
* from completing successfully, we record any events other than errors, then on completion we
* show them.
*
* This is a simple way to only show errors when an error occur.
*/
workflow.reporter.subscribe((event: DryRunEvent) => {
nothingDone = false;
const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; switch (event.kind) {
logger.warn(`ERROR! ${event.path} ${desc}.`); case 'error':
break; error = true;
case 'update':
loggingQueue.push(tags.oneLine` const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist';
logger.warn(`ERROR! ${event.path} ${desc}.`);
break;
case 'update':
loggingQueue.push(tags.oneLine`
${terminal.white('UPDATE')} ${event.path} (${event.content.length} bytes) ${terminal.white('UPDATE')} ${event.path} (${event.content.length} bytes)
`); `);
break; break;
case 'create': case 'create':
loggingQueue.push(tags.oneLine` loggingQueue.push(tags.oneLine`
${terminal.green('CREATE')} ${event.path} (${event.content.length} bytes) ${terminal.green('CREATE')} ${event.path} (${event.content.length} bytes)
`); `);
break; break;
case 'delete': case 'delete':
loggingQueue.push(`${terminal.yellow('DELETE')} ${event.path}`); loggingQueue.push(`${terminal.yellow('DELETE')} ${event.path}`);
break; break;
case 'rename': case 'rename':
loggingQueue.push(`${terminal.blue('RENAME')} ${event.path} => ${event.to}`); loggingQueue.push(`${terminal.blue('RENAME')} ${event.path} => ${event.to}`);
break; break;
}
});
/**
* Listen to lifecycle events of the workflow to flush the logs between each phases.
*/
workflow.lifeCycle.subscribe(event => {
if (event.kind == 'workflow-end' || event.kind == 'post-tasks-start') {
if (!error) {
// Flush the log queue and clean the error state.
loggingQueue.forEach(log => logger.info(log));
}
loggingQueue = [];
error = false;
}
});
/**
* Remove every options from argv that we support in schematics itself.
*/
const parsedArgs = Object.assign({}, argv);
delete parsedArgs['--'];
for (const key of booleanArgs) {
delete parsedArgs[key];
} }
});
/**
* Add options from `--` to args.
*/
const argv2 = minimist(argv['--']);
for (const key of Object.keys(argv2)) {
parsedArgs[key] = argv2[key];
}
// Pass the rest of the arguments as the smart default "argv". Then delete it.
workflow.registry.addSmartDefaultProvider('argv', (schema: JsonObject) => {
if ('index' in schema) {
return argv._[Number(schema['index'])];
} else {
return argv._;
}
});
delete parsedArgs._;
/** /**
* Listen to lifecycle events of the workflow to flush the logs between each phases. * Execute the workflow, which will report the dry run events, run the tasks, and complete
*/ * after all is done.
workflow.lifeCycle.subscribe(event => { *
if (event.kind == 'workflow-end' || event.kind == 'post-tasks-start') { * The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY
if (!error) { * step of the workflow failed (sink or task), with details included, and will only complete
// Flush the log queue and clean the error state. * when everything is done.
loggingQueue.forEach(log => logger.info(log)); */
try {
await workflow.execute({
collection: collectionName,
schematic: schematicName,
options: parsedArgs,
allowPrivate: allowPrivate,
debug: debug,
logger: logger,
})
.toPromise();
if (nothingDone) {
logger.info('Nothing to be done.');
} }
loggingQueue = []; return 0;
error = false;
}
});
} catch (err) {
/**
* Remove every options from argv that we support in schematics itself.
*/
const args = Object.assign({}, argv);
delete args['--'];
for (const key of booleanArgs) {
delete args[key];
}
/**
* Add options from `--` to args.
*/
const argv2 = minimist(argv['--']);
for (const key of Object.keys(argv2)) {
args[key] = argv2[key];
}
// Pass the rest of the arguments as the smart default "argv". Then delete it.
workflow.registry.addSmartDefaultProvider('argv', (schema: JsonObject) => {
if ('index' in schema) {
return argv._[Number(schema['index'])];
} else {
return argv._;
}
});
delete args._;
/**
* Execute the workflow, which will report the dry run events, run the tasks, and complete
* after all is done.
*
* The Observable returned will properly cancel the workflow if unsubscribed, error out if ANY
* step of the workflow failed (sink or task), with details included, and will only complete
* when everything is done.
*/
workflow.execute({
collection: collectionName,
schematic: schematicName,
options: args,
allowPrivate: allowPrivate,
debug: debug,
logger: logger,
})
.subscribe({
error(err: Error) {
// In case the workflow was not successful, show an appropriate error message.
if (err instanceof UnsuccessfulWorkflowExecution) { if (err instanceof UnsuccessfulWorkflowExecution) {
// "See above" because we already printed the error. // "See above" because we already printed the error.
logger.fatal('The Schematic workflow failed. See above.'); logger.fatal('The Schematic workflow failed. See above.');
@ -278,11 +264,46 @@ workflow.execute({
logger.fatal(err.stack || err.message); logger.fatal(err.stack || err.message);
} }
process.exit(1); return 1;
}, }
complete() { }
if (nothingDone) {
logger.info('Nothing to be done.'); /**
} * Get usage of the CLI tool.
}, */
}); function getUsage(): string {
return tags.stripIndent`
schematics [CollectionName:]SchematicName [options, ...]
By default, if the collection name is not specified, use the internal collection provided
by the Schematics CLI.
Options:
--debug Debug mode. This is true by default if the collection is a relative
path (in that case, turn off with --debug=false).
--allowPrivate Allow private schematics to be run from the command line. Default to
false.
--dry-run Do not output anything, but instead just show what actions would be
performed. Default to true if debug is also true.
--force Force overwriting files that would otherwise be an error.
--list-schematics List all schematics from the collection, by name. A collection name
should be suffixed by a colon. Example: '@schematics/schematics:'.
--verbose Show more information.
--help Show this message.
Any additional option is passed to the Schematics depending on
`;
}
if (require.main === module) {
const args = process.argv.slice(2);
main({ args })
.then(exitCode => process.exitCode = exitCode)
.catch(e => { throw (e); });
}