commands/test.ts
import {
assign
} from 'lodash';
import * as path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { ui, Command, Project, unwrap } from '@denali-js/cli';
/**
* Run your app's test suite
*
* @package commands
*/
export default class TestCommand extends Command {
/* tslint:disable:completed-docs typedef */
static commandName = 'test';
static description = "Run your app's test suite";
static longDescription = unwrap`
Runs your app's test suite, and can optionally keep re-running it on each file change (--watch).
`;
static runsInApp = true;
static params = '[files...]';
static flags = {
environment: {
description: 'The target environment to build for.',
default: process.env.NODE_ENV || 'test',
type: <any>'string'
},
debug: {
description: 'The test file you want to debug. Can only debug one file at a time.',
type: <any>'boolean'
},
watch: {
description: 'Re-run the tests when the source files change',
default: false,
type: <any>'boolean'
},
match: {
description: 'Filter which tests run based on the supplied regex pattern',
type: <any>'string'
},
timeout: {
description: 'Set the timeout for all tests, i.e. --timeout 10s, --timeout 2m',
type: <any>'string'
},
skipLint: {
description: 'Skip linting the app source files',
default: false,
type: <any>'boolean'
},
skipAudit: {
description: 'Skip auditing your package.json for vulnerabilites',
default: false,
type: <any>'boolean'
},
verbose: {
description: 'Print detailed output of the status of your test run',
default: process.env.CI,
type: <any>'boolean'
},
printSlowTrees: {
description: 'Print out an analysis of the build process, showing the slowest nodes.',
default: false,
type: <any>'boolean'
},
failFast: {
description: 'Stop tests on the first failure',
default: false,
type: <any>'boolean'
},
litter: {
description: 'Do not clean up tmp directories created during testing (useful for debugging)',
default: false,
type: <any>'boolean'
},
serial: {
description: 'Run tests serially',
default: false,
type: <any>'boolean'
},
concurrency: {
description: 'How many test files should run concurrently?',
default: 5,
type: <any>'number'
}
};
tests: ChildProcess;
async run(argv: any) {
let files = (<string[] | void>argv.files) || [];
if (files.length === 0) {
files.push('test/**/*.js');
} else {
// Swap common file extensions out with `.js` so ava will find the actual, built files. This
// doesn't cover every possible edge case, hence the `isValidJsPattern` below, but it should
// cover the common use cases.
files = files.map((pattern) => pattern.replace(/\.[A-z0-9]{1,4}$/, '.js'));
}
// Filter for .js files only
files = files.filter((pattern: string) => {
let isValidJsPattern = pattern.endsWith('*') || pattern.endsWith('.js');
if (!isValidJsPattern) {
ui.warn(unwrap`
If you want to run specific test files, you must use the .js extension. You supplied
${ pattern }. Denali will build your test files before running them, so you need to use
the compiled filename which ends in .js
`);
}
return isValidJsPattern;
});
let project = new Project({
environment: argv.environment,
printSlowTrees: argv.printSlowTrees
});
let outputDir = project.isAddon ? path.join('tmp', '-dummy') : 'dist';
process.on('exit', this.cleanExit.bind(this));
process.on('SIGINT', this.cleanExit.bind(this, true));
process.on('SIGTERM', this.cleanExit.bind(this, true));
if (argv.watch) {
let watch: typeof project.watch = project.isAddon ? project.watchDummy.bind(project) : project.watch.bind(project);
watch({
destDir: outputDir,
// Don't let broccoli rebuild while tests are still running, or else
// we'll be removing the test files while in progress leading to cryptic
// errors.
beforeRebuild: async () => {
if (this.tests) {
return new Promise<void>((resolve) => {
this.tests.removeAllListeners('exit');
this.tests.on('exit', () => {
delete this.tests;
resolve();
});
this.tests.kill();
ui.info('\n\n===> Changes detected, cancelling in-progress tests ...\n\n');
});
}
},
afterBuild: this.runTests.bind(this, files, project, outputDir, argv)
});
} else {
try {
project.isAddon ? await project.buildDummy(outputDir) : await project.build(outputDir);
this.runTests(files, project, outputDir, argv);
} catch (error) {
process.exitCode = 1;
throw error;
}
}
}
protected cleanExit(resumeExit: boolean) {
if (this.tests) {
this.tests.kill();
}
if (resumeExit) {
process.exit();
}
}
protected runTests(files: string[], project: Project, outputDir: string, argv: any) {
let avaPath = path.join(process.cwd(), 'node_modules', '.bin', 'ava');
files = files.map((pattern) => path.join(outputDir, pattern));
let args = files.concat([ '--concurrency', argv.concurrency ]);
if (argv.debug) {
avaPath = process.execPath;
args = [ '--inspect-brk', path.join(process.cwd(), 'node_modules', 'ava', 'profile.js'), ...files ];
}
if (argv.match) {
args.push('--match', argv.match);
}
if (argv.verbose) {
args.unshift('--verbose');
}
if (argv.timeout) {
args.unshift('--timeout', argv.timeout);
}
if (argv.failFast) {
args.unshift('--fail-fast');
}
if (argv.serial) {
args.unshift('--serial');
}
this.tests = spawn(avaPath, args, {
stdio: [ 'pipe', process.stdout, process.stderr ],
env: assign({}, process.env, {
PORT: argv.port,
DENALI_LEAVE_TMP: argv.litter,
NODE_ENV: project.environment,
DEBUG_COLORS: 1,
DENALI_TEST_BUILD_DIR: outputDir
})
});
ui.info(`===> Running ${ project.pkg.name } tests ...`);
this.tests.on('exit', (code: number | null) => {
if (code === 0) {
ui.success('\n===> Tests passed 👍');
} else {
ui.error('\n===> Tests failed 💥');
}
delete this.tests;
if (argv.watch) {
ui.info('===> Waiting for changes to re-run ...\n\n');
} else {
process.exitCode = code == null ? 1 : code;
}
});
}
}