Browse Source

Close #502 PR: Implement --watch. Fixes #70

babel-plugin-for-integration-tests
Mark Wubben 9 years ago
committed by Sindre Sorhus
parent
commit
f0f4f34438
  1. 91
      api.js
  2. 46
      cli.js
  3. 8
      lib/logger.js
  4. 32
      lib/reporters/mini.js
  5. 15
      lib/reporters/tap.js
  6. 4
      lib/reporters/verbose.js
  7. 203
      lib/watcher.js
  8. 11
      package.json
  9. 201
      test/api.js
  10. 23
      test/reporters/mini.js
  11. 18
      test/reporters/tap.js
  12. 14
      test/reporters/verbose.js
  13. 644
      test/watcher.js

91
api.js

@ -9,7 +9,7 @@ var figures = require('figures');
var globby = require('globby');
var chalk = require('chalk');
var objectAssign = require('object-assign');
var commondir = require('commondir');
var commonPathPrefix = require('common-path-prefix');
var resolveCwd = require('resolve-cwd');
var uniqueTempDir = require('unique-temp-dir');
var findCacheDir = require('find-cache-dir');
@ -27,6 +27,34 @@ function Api(files, options) {
this.options = options || {};
this.options.require = (this.options.require || []).map(resolveCwd);
if (!files || files.length === 0) {
this.files = [
'test.js',
'test-*.js',
'test'
];
} else {
this.files = files;
}
this.excludePatterns = [
'!**/node_modules/**',
'!**/fixtures/**',
'!**/helpers/**'
];
Object.keys(Api.prototype).forEach(function (key) {
this[key] = this[key].bind(this);
}, this);
this._reset();
}
util.inherits(Api, EventEmitter);
module.exports = Api;
Api.prototype._reset = function () {
this.rejectionCount = 0;
this.exceptionCount = 0;
this.passCount = 0;
@ -37,16 +65,9 @@ function Api(files, options) {
this.errors = [];
this.stats = [];
this.tests = [];
this.files = files || [];
this.base = '';
Object.keys(Api.prototype).forEach(function (key) {
this[key] = this[key].bind(this);
}, this);
}
util.inherits(Api, EventEmitter);
module.exports = Api;
this.explicitTitles = false;
};
Api.prototype._runFile = function (file) {
var options = objectAssign({}, this.options, {
@ -119,7 +140,7 @@ Api.prototype._handleTest = function (test) {
};
Api.prototype._prefixTitle = function (file) {
if (this.fileCount === 1) {
if (this.fileCount === 1 && !this.explicitTitles) {
return '';
}
@ -141,16 +162,23 @@ Api.prototype._prefixTitle = function (file) {
return prefix;
};
Api.prototype.run = function () {
Api.prototype.run = function (files) {
var self = this;
return handlePaths(this.files)
this._reset();
this.explicitTitles = Boolean(files);
return handlePaths(files || this.files, this.excludePatterns)
.map(function (file) {
return path.resolve(file);
})
.then(function (files) {
if (files.length === 0) {
return Promise.reject(new AvaError('Couldn\'t find any files to test'));
self._handleExceptions({
exception: new AvaError('Couldn\'t find any files to test'),
file: undefined
});
return [];
}
var cacheEnabled = self.options.cacheEnabled !== false;
@ -160,7 +188,7 @@ Api.prototype.run = function () {
self.options.cacheDir = cacheDir;
self.precompiler = new CachingPrecompiler(cacheDir);
self.fileCount = files.length;
self.base = path.relative('.', commondir('.', files)) + path.sep;
self.base = path.relative('.', commonPathPrefix(files)) + path.sep;
var tests = files.map(self._runFile);
@ -182,7 +210,20 @@ Api.prototype.run = function () {
var method = self.options.serial ? 'mapSeries' : 'map';
resolve(Promise[method](files, function (file, index) {
return tests[index].run();
return tests[index].run().catch(function (err) {
// The test failed catastrophically. Flag it up as an
// exception, then return an empty result. Other tests may
// continue to run.
self._handleExceptions({
exception: err,
file: file
});
return {
stats: {passCount: 0, skipCount: 0, failCount: 0},
tests: []
};
});
}));
}
}
@ -210,26 +251,14 @@ Api.prototype.run = function () {
});
};
function handlePaths(files) {
if (files.length === 0) {
files = [
'test.js',
'test-*.js',
'test'
];
}
files.push('!**/node_modules/**');
files.push('!**/fixtures/**');
files.push('!**/helpers/**');
function handlePaths(files, excludePatterns) {
// convert pinkie-promise to Bluebird promise
files = Promise.resolve(globby(files));
files = Promise.resolve(globby(files.concat(excludePatterns)));
return files
.map(function (file) {
if (fs.statSync(file).isDirectory()) {
return handlePaths([path.join(file, '**', '*.js')]);
return handlePaths([path.join(file, '**', '*.js')], excludePatterns);
}
return file;

46
cli.js

@ -29,6 +29,7 @@ var verboseReporter = require('./lib/reporters/verbose');
var miniReporter = require('./lib/reporters/mini');
var tapReporter = require('./lib/reporters/tap');
var Logger = require('./lib/logger');
var watcher = require('./lib/watcher');
var Api = require('./api');
// Bluebird specific
@ -48,6 +49,9 @@ var cli = meow([
' --tap, -t Generate TAP output',
' --verbose, -v Enable verbose output',
' --no-cache Disable the transpiler cache',
// Leave --watch and --sources undocumented until they're stable enough
// ' --watch, -w Re-run tests when tests and source files change',
// ' --source Pattern to match source files so tests can be re-run (Can be repeated)',
'',
'Examples',
' ava',
@ -62,20 +66,23 @@ var cli = meow([
], {
string: [
'_',
'require'
'require',
'source'
],
boolean: [
'fail-fast',
'verbose',
'serial',
'tap'
'tap',
'watch'
],
default: conf,
alias: {
t: 'tap',
v: 'verbose',
r: 'require',
s: 'serial'
s: 'serial',
w: 'watch'
}
});
@ -112,17 +119,30 @@ api.on('error', logger.unhandledError);
api.on('stdout', logger.stdout);
api.on('stderr', logger.stderr);
api.run()
.then(function () {
logger.finish();
logger.exit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0);
})
.catch(function (err) {
if (cli.flags.watch) {
try {
watcher.start(logger, api, arrify(cli.flags.source), process.stdin);
} catch (err) {
if (err.name === 'AvaError') {
// An AvaError may be thrown if chokidar is not installed. Log it nicely.
console.log(' ' + colors.error(figures.cross) + ' ' + err.message);
logger.exit(1);
} else {
console.error(colors.stack(err.stack));
// Rethrow so it becomes an uncaught exception.
throw err;
}
logger.exit(1);
});
}
} else {
api.run()
.then(function () {
logger.finish();
logger.exit(api.failCount > 0 || api.rejectionCount > 0 || api.exceptionCount > 0 ? 1 : 0);
})
.catch(function (err) {
// Don't swallow exceptions. Note that any expected error should already
// have been logged.
setImmediate(function () {
throw err;
});
});
}

8
lib/logger.js

@ -25,6 +25,14 @@ Logger.prototype.start = function () {
this.write(this.reporter.start());
};
Logger.prototype.reset = function () {
if (!this.reporter.reset) {
return;
}
this.write(this.reporter.reset());
};
Logger.prototype.test = function (test) {
this.write(this.reporter.test(test));
};

32
lib/reporters/mini.js

@ -10,14 +10,7 @@ function MiniReporter() {
return new MiniReporter();
}
this.passCount = 0;
this.failCount = 0;
this.skipCount = 0;
this.rejectionCount = 0;
this.exceptionCount = 0;
this.currentStatus = '';
this.statusLineCount = 0;
this.lastLineTracker = lastLineTracker();
this.reset();
this.stream = process.stderr;
this.stringDecoder = new StringDecoder();
}
@ -28,6 +21,17 @@ MiniReporter.prototype.start = function () {
return '';
};
MiniReporter.prototype.reset = function () {
this.passCount = 0;
this.failCount = 0;
this.skipCount = 0;
this.rejectionCount = 0;
this.exceptionCount = 0;
this.currentStatus = '';
this.statusLineCount = 0;
this.lastLineTracker = lastLineTracker();
};
MiniReporter.prototype.test = function (test) {
var status = '';
var title;
@ -120,11 +124,15 @@ MiniReporter.prototype.finish = function () {
i++;
var title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception';
var description = err.stack ? err.stack : JSON.stringify(err);
if (err.type === 'exception' && err.name === 'AvaError') {
status += '\n\n ' + colors.error(i + '. ' + err.message) + '\n';
} else {
var title = err.type === 'rejection' ? 'Unhandled Rejection' : 'Uncaught Exception';
var description = err.stack ? err.stack : JSON.stringify(err);
status += '\n\n ' + colors.error(i + '.', title) + '\n';
status += ' ' + colors.stack(description);
status += '\n\n ' + colors.error(i + '.', title) + '\n';
status += ' ' + colors.stack(description);
}
});
}

15
lib/reporters/tap.js

@ -51,12 +51,17 @@ TapReporter.prototype.test = function (test) {
TapReporter.prototype.unhandledError = function (err) {
var output = [
'# ' + err.message,
format('not ok %d - %s', ++this.i, err.message),
' ---',
' name: ' + err.name,
' at: ' + getSourceFromStack(err.stack, 1),
' ...'
format('not ok %d - %s', ++this.i, err.message)
];
// AvaErrors don't have stack traces.
if (err.type !== 'exception' || err.name !== 'AvaError') {
output.push(
' ---',
' name: ' + err.name,
' at: ' + getSourceFromStack(err.stack, 1),
' ...'
);
}
return output.join('\n');
};

4
lib/reporters/verbose.js

@ -37,6 +37,10 @@ VerboseReporter.prototype.test = function (test) {
};
VerboseReporter.prototype.unhandledError = function (err) {
if (err.type === 'exception' && err.name === 'AvaError') {
return ' ' + colors.error(figures.cross) + ' ' + err.message;
}
var types = {
rejection: 'Unhandled Rejection',
exception: 'Uncaught Exception'

203
lib/watcher.js

@ -0,0 +1,203 @@
'use strict';
var AvaError = require('./ava-error');
var debug = require('debug')('ava:watcher');
var defaultIgnore = require('ignore-by-default').directories();
var multimatch = require('multimatch');
var nodePath = require('path');
var Promise = require('bluebird');
function requireChokidar() {
try {
return require('chokidar');
} catch (err) {
throw new AvaError('The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.');
}
}
function rethrowAsync(err) {
// Don't swallow exceptions. Note that any expected error should already have
// been logged.
setImmediate(function () {
throw err;
});
}
function getChokidarPatterns(sources, initialFiles) {
var paths = [];
var ignored = [];
sources.forEach(function (pattern) {
if (pattern[0] === '!') {
ignored.push(pattern.slice(1));
} else {
paths.push(pattern);
}
});
if (paths.length === 0) {
paths = ['package.json', '**/*.js'];
}
paths = paths.concat(initialFiles);
if (ignored.length === 0) {
ignored = defaultIgnore;
}
return {paths: paths, ignored: ignored};
}
exports.start = function (logger, api, sources, stdin) {
var isTest = makeTestMatcher(api.files, api.excludePatterns);
var patterns = getChokidarPatterns(sources, api.files);
var watcher = requireChokidar().watch(patterns.paths, {
ignored: patterns.ignored,
ignoreInitial: true
});
var busy = api.run().then(function () {
logger.finish();
}).catch(rethrowAsync);
var dirtyStates = {};
watcher.on('all', function (event, path) {
if (event === 'add' || event === 'change' || event === 'unlink') {
debug('Detected %s of %s', event, path);
dirtyStates[path] = event;
debounce();
}
});
var debouncing = null;
var debounceAgain = false;
function debounce() {
if (debouncing) {
debounceAgain = true;
return;
}
var timer = debouncing = setTimeout(function () {
busy.then(function () {
// Do nothing if debouncing was canceled while waiting for the busy
// promise to fulfil.
if (debouncing !== timer) {
return;
}
if (debounceAgain) {
debouncing = null;
debounceAgain = false;
debounce();
} else {
busy = runAfterChanges(logger, api, isTest, dirtyStates);
dirtyStates = {};
debouncing = null;
debounceAgain = false;
}
});
}, 10);
}
function cancelDebounce() {
if (debouncing) {
clearTimeout(debouncing);
debouncing = null;
debounceAgain = false;
}
}
stdin.resume();
stdin.setEncoding('utf8');
stdin.on('data', function (data) {
data = data.trim().toLowerCase();
if (data !== 'rs') {
return;
}
// Cancel the debouncer, it might rerun specific tests whereas *all* tests
// need to be rerun.
cancelDebounce();
busy.then(function () {
// Cancel the debouncer again, it might have restarted while waiting for
// the busy promise to fulfil.
cancelDebounce();
busy = runAfterChanges(logger, api, isTest, {});
});
});
};
function makeTestMatcher(files, excludePatterns) {
var initialPatterns = files.concat(excludePatterns);
return function (path) {
// Like in api.js, tests must be .js files and not start with _
if (nodePath.extname(path) !== '.js' || nodePath.basename(path)[0] === '_') {
return false;
}
// Check if the entire path matches a pattern.
if (multimatch(path, initialPatterns).length === 1) {
return true;
}
// Check if the path contains any directory components.
var dirname = nodePath.dirname(path);
if (dirname === '.') {
return false;
}
// Compute all possible subpaths. Note that the dirname is assumed to be
// relative to the working directory, without a leading `./`.
var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) {
var parent = subpaths[subpaths.length - 1];
if (parent) {
subpaths.push(nodePath.join(parent, component));
} else {
subpaths.push(component);
}
return subpaths;
}, []);
// Check if any of the possible subpaths match a pattern. If so, generate a
// new pattern with **/*.js.
var recursivePatterns = subpaths.filter(function (subpath) {
return multimatch(subpath, initialPatterns).length === 1;
}).map(function (subpath) {
return nodePath.join(subpath, '**', '*.js');
});
// See if the entire path matches any of the subpaths patterns, taking the
// excludePatterns into account. This mimicks the behavior in api.js
return multimatch(path, recursivePatterns.concat(excludePatterns)).length === 1;
};
}
function runAfterChanges(logger, api, isTest, dirtyStates) {
var dirtyPaths = Object.keys(dirtyStates);
var dirtyTests = dirtyPaths.filter(isTest);
var addedOrChangedTests = dirtyTests.filter(function (path) {
return dirtyStates[path] !== 'unlink';
});
var unlinkedTests = dirtyTests.filter(function (path) {
return dirtyStates[path] === 'unlink';
});
// No need to rerun tests if the only change is that tests were deleted.
if (dirtyPaths.length > 0 && unlinkedTests.length === dirtyPaths.length) {
return Promise.resolve();
}
return new Promise(function (resolve) {
logger.reset();
// Run any new or changed tests, unless non-test files were changed too.
// In that case rerun the entire test suite.
if (dirtyPaths.length > 0 && dirtyTests.length === dirtyPaths.length) {
resolve(api.run(addedOrChangedTests));
} else {
resolve(api.run());
}
}).then(function () {
logger.finish();
}).catch(rethrowAsync);
}

11
package.json

@ -80,7 +80,7 @@
"chalk": "^1.0.0",
"cli-cursor": "^1.0.2",
"co-with-promise": "^4.6.0",
"commondir": "^1.0.1",
"common-path-prefix": "^1.0.0",
"convert-source-map": "^1.1.2",
"core-assert": "^0.1.0",
"debug": "^2.2.0",
@ -90,6 +90,7 @@
"find-cache-dir": "^0.1.1",
"fn-name": "^2.0.0",
"globby": "^4.0.0",
"ignore-by-default": "^1.0.0",
"is-ci": "^1.0.7",
"is-generator-fn": "^1.0.0",
"is-observable": "^0.1.0",
@ -99,6 +100,7 @@
"max-timeout": "^1.0.0",
"md5-hex": "^1.2.0",
"meow": "^3.7.0",
"multimatch": "^2.1.0",
"object-assign": "^4.0.1",
"observable-to-promise": "^0.3.0",
"option-chain": "^0.1.0",
@ -125,17 +127,22 @@
"get-stream": "^1.1.0",
"git-branch": "^0.3.0",
"inquirer": "^0.11.1",
"lolex": "^1.4.0",
"mkdirp": "^0.5.1",
"nyc": "^5.1.0",
"pify": "^2.3.0",
"proxyquire": "^1.7.4",
"rimraf": "^2.5.0",
"signal-exit": "^2.1.2",
"sinon": "^1.17.2",
"source-map-fixtures": "^1.0.0",
"tap": "^5.0.1",
"tap": "^5.4.2",
"xo": "*",
"zen-observable": "^0.1.6"
},
"optionalDependencies": {
"chokidar": "^1.4.2"
},
"xo": {
"ignore": [
"cli.js",

201
test/api.js

@ -39,7 +39,7 @@ test('async/await support', function (t) {
});
});
test('test title prefixes', function (t) {
test('test title prefixes — multiple files', function (t) {
t.plan(6);
var separator = ' ' + figures.pointerSmall + ' ';
@ -77,6 +77,68 @@ test('test title prefixes', function (t) {
});
});
test('test title prefixes — single file', function (t) {
t.plan(2);
var separator = ' ' + figures.pointerSmall + ' ';
var files = [
path.join(__dirname, 'fixture/generators.js')
];
var expected = [
['generator function'].join(separator)
];
var index;
var api = new Api(files);
api.run()
.then(function () {
// if all lines were removed from expected output
// actual output matches expected output
t.is(expected.length, 0);
});
api.on('test', function (a) {
index = expected.indexOf(a.title);
t.true(index >= 0);
// remove line from expected output
expected.splice(index, 1);
});
});
test('test title prefixes — single file (explicit)', function (t) {
t.plan(2);
var separator = ' ' + figures.pointerSmall + ' ';
var files = [
path.join(__dirname, 'fixture/generators.js')
];
var expected = [
['generators', 'generator function'].join(separator)
];
var index;
var api = new Api();
api.run(files)
.then(function () {
// if all lines were removed from expected output
// actual output matches expected output
t.is(expected.length, 0);
});
api.on('test', function (a) {
index = expected.indexOf(a.title);
t.true(index >= 0);
// remove line from expected output
expected.splice(index, 1);
});
});
test('display filename prefixes for failed test stack traces', function (t) {
t.plan(3);
@ -296,7 +358,7 @@ test('absolute paths', function (t) {
});
});
test('search directories recursivly for files', function (t) {
test('search directories recursively for files', function (t) {
t.plan(2);
var api = new Api([path.join(__dirname, 'fixture/subdir')]);
@ -321,28 +383,30 @@ test('titles of both passing and failing tests and AssertionErrors are returned'
});
});
test('empty test files creates a failure with a helpful warning', function (t) {
test('empty test files cause an AvaError to be emitted', function (t) {
t.plan(2);
var api = new Api([path.join(__dirname, 'fixture/empty.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /No tests found.*?import "ava"/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /No tests found.*?import "ava"/);
});
return api.run();
});
test('test file with no tests creates a failure with a helpful warning', function (t) {
test('test file with no tests causes an AvaError to be emitted', function (t) {
t.plan(2);
var api = new Api([path.join(__dirname, 'fixture/no-tests.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /No tests/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /No tests/);
});
return api.run();
});
test('test file that immediately exits with 0 exit code ', function (t) {
@ -350,23 +414,25 @@ test('test file that immediately exits with 0 exit code ', function (t) {
var api = new Api([path.join(__dirname, 'fixture/immediate-0-exit.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /Test results were not received from/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Test results were not received from/);
});
return api.run();
});
test('testing nonexistent files rejects', function (t) {
test('testing nonexistent files causes an AvaError to be emitted', function (t) {
t.plan(2);
var api = new Api([path.join(__dirname, 'fixture/broken.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /Couldn't find any files to test/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run();
});
test('test file in node_modules is ignored', function (t) {
@ -374,11 +440,25 @@ test('test file in node_modules is ignored', function (t) {
var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /Couldn't find any files to test/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run();
});
test('test file in node_modules is ignored (explicit)', function (t) {
t.plan(2);
var api = new Api();
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run([path.join(__dirname, 'fixture/ignored-dirs/node_modules/test.js')]);
});
test('test file in fixtures is ignored', function (t) {
@ -386,11 +466,25 @@ test('test file in fixtures is ignored', function (t) {
var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /Couldn't find any files to test/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run();
});
test('test file in fixtures is ignored (explicit)', function (t) {
t.plan(2);
var api = new Api();
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run([path.join(__dirname, 'fixture/ignored-dirs/fixtures/test.js')]);
});
test('test file in helpers is ignored', function (t) {
@ -398,11 +492,25 @@ test('test file in helpers is ignored', function (t) {
var api = new Api([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]);
api.run()
.catch(function (err) {
t.ok(err);
t.match(err.message, /Couldn't find any files to test/);
});
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run();
});
test('test file in helpers is ignored (explicit)', function (t) {
t.plan(2);
var api = new Api();
api.on('error', function (err) {
t.is(err.name, 'AvaError');
t.match(err.message, /Couldn't find any files to test/);
});
return api.run([path.join(__dirname, 'fixture/ignored-dirs/helpers/test.js')]);
});
test('Node.js-style --require CLI argument', function (t) {
@ -488,3 +596,16 @@ test('test file with only skipped tests does not create a failure', function (t)
t.true(api.tests[0].skip);
});
});
test('resets state before running', function (t) {
t.plan(2);
var api = new Api([path.resolve('test/fixture/es2015.js')]);
api.run().then(function () {
t.is(api.passCount, 1);
return api.run();
}).then(function () {
t.is(api.passCount, 1);
});
});

23
test/reporters/mini.js

@ -1,6 +1,7 @@
'use strict';
var chalk = require('chalk');
var test = require('tap').test;
var AvaError = require('../../lib/ava-error');
var miniReporter = require('../../lib/reporters/mini');
var beautifyStack = require('../../lib/beautify-stack');
@ -140,25 +141,30 @@ test('results with passing tests and rejections', function (t) {
test('results with passing tests and exceptions', function (t) {
var reporter = miniReporter();
reporter.passCount = 1;
reporter.exceptionCount = 1;
reporter.exceptionCount = 2;
var err = new Error('failure');
err.type = 'exception';
err.stack = beautifyStack(err.stack);
var avaErr = new AvaError('A futuristic test runner');
avaErr.type = 'exception';
reporter.api = {
errors: [err]
errors: [err, avaErr]
};
var output = reporter.finish().split('\n');
t.is(output[0], '');
t.is(output[1], ' ' + chalk.green('1 passed'));
t.is(output[2], ' ' + chalk.red('1 exception'));
t.is(output[2], ' ' + chalk.red('2 exceptions'));
t.is(output[3], '');
t.is(output[4], ' ' + chalk.red('1. Uncaught Exception'));
t.match(output[5], /Error: failure/);
t.match(output[6], /test\/reporters\/mini\.js/);
var next = 6 + output.slice(6).indexOf('') + 1;
t.is(output[next], ' ' + chalk.red('2. A futuristic test runner'));
t.end();
});
@ -187,3 +193,14 @@ test('results with errors', function (t) {
t.match(output[6], /test\/reporters\/mini\.js/);
t.end();
});
test('empty results after reset', function (t) {
var reporter = miniReporter();
reporter.failCount = 1;
reporter.reset();
var output = reporter.finish();
t.is(output, '\n\n');
t.end();
});

18
test/reporters/tap.js

@ -76,6 +76,24 @@ test('unhandled error', function (t) {
t.end();
});
test('ava error', function (t) {
var reporter = tapReporter();
var actualOutput = reporter.unhandledError({
type: 'exception',
name: 'AvaError',
message: 'A futuristic test runner'
});
var expectedOutput = [
'# A futuristic test runner',
'not ok 1 - A futuristic test runner'
].join('\n');
t.is(actualOutput, expectedOutput);
t.end();
});
test('results', function (t) {
var reporter = tapReporter();
var api = {

14
test/reporters/verbose.js

@ -121,6 +121,20 @@ test('uncaught exception', function (t) {
t.end();
});
test('ava error', function (t) {
var reporter = createReporter();
var output = reporter.unhandledError({
type: 'exception',
file: 'test.js',
name: 'AvaError',
message: 'A futuristic test runner'
}).split('\n');
t.is(output[0], chalk.red(' ' + figures.cross + ' A futuristic test runner'));
t.end();
});
test('unhandled rejection', function (t) {
var reporter = createReporter();

644
test/watcher.js

@ -0,0 +1,644 @@
'use strict';
var Promise = require('bluebird');
var EventEmitter = require('events').EventEmitter;
var defaultIgnore = require('ignore-by-default').directories();
var lolex = require('lolex');
var path = require('path');
var proxyquire = require('proxyquire');
var sinon = require('sinon');
var PassThrough = require('stream').PassThrough;
var test = require('tap').test;
var setImmediate = require('../lib/globals').setImmediate;
test('chokidar is not installed', function (t) {
t.plan(2);
var subject = proxyquire.noCallThru().load('../lib/watcher', {
chokidar: null
});
try {
subject.start({}, {files: [], excludePatterns: []}, []);
} catch (err) {
t.is(err.name, 'AvaError');
t.is(err.message, 'The optional dependency chokidar failed to install and is required for --watch. Chokidar is likely not supported on your platform.');
}
});
test('chokidar is installed', function (_t) {
var chokidar = {
watch: sinon.stub()
};
var debug = sinon.spy();
var logger = {
finish: sinon.spy(),
reset: sinon.spy()
};
var api = {
run: sinon.stub()
};
var subject = proxyquire.noCallThru().load('../lib/watcher', {
chokidar: chokidar,
debug: function (name) {
return function () {
var args = [name];
args.push.apply(args, arguments);
debug.apply(null, args);
};
}
});
var clock;
var emitter;
var stdin;
_t.beforeEach(function (done) {
if (clock) {
clock.uninstall();
}
clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']);
emitter = new EventEmitter();
chokidar.watch.reset();
chokidar.watch.returns(emitter);
debug.reset();
logger.finish.reset();
logger.reset.reset();
api.run.reset();
api.run.returns(new Promise(function () {}));
api.files = [
'test.js',
'test-*.js',
'test'
];
api.excludePatterns = [
'!**/node_modules/**',
'!**/fixtures/**',
'!**/helpers/**'
];
stdin = new PassThrough();
stdin.pause();
done();
});
var start = function (sources) {
subject.start(logger, api, sources || [], stdin);
};
var add = function (path) {
emitter.emit('all', 'add', path || 'source.js');
};
var change = function (path) {
emitter.emit('all', 'change', path || 'source.js');
};
var unlink = function (path) {
emitter.emit('all', 'unlink', path || 'source.js');
};
var delay = function () {
return new Promise(function (now) {
setImmediate(now);
});
};
// Advance the clock to get past the debounce timeout, then wait for a promise
// to be resolved to get past the busy.then() delay.
var debounce = function (times) {
times = times >= 0 ? times : 1;
clock.next();
return delay().then(function () {
if (times > 1) {
return debounce(times - 1);
}
});
};
var pending = [];
var test = function (name, fn) {
pending.push(_t.test(name, fn));
};
test('watches for default source file changes, as well as test files', function (t) {
t.plan(2);
start();
t.ok(chokidar.watch.calledOnce);
t.same(chokidar.watch.firstCall.args, [
['package.json', '**/*.js'].concat(api.files),
{
ignored: defaultIgnore,
ignoreInitial: true
}
]);
});
test('watched source files are configurable', function (t) {
t.plan(2);
start(['foo.js', '!bar.js', 'baz.js', '!qux.js']);
t.ok(chokidar.watch.calledOnce);
t.same(chokidar.watch.firstCall.args, [
['foo.js', 'baz.js'].concat(api.files),
{
ignored: ['bar.js', 'qux.js'],
ignoreInitial: true
}
]);
});
test('default set of ignored files if configured sources does not contain exclusion patterns', function (t) {
t.plan(2);
start(['foo.js', 'baz.js']);
t.ok(chokidar.watch.calledOnce);
t.same(chokidar.watch.firstCall.args, [
['foo.js', 'baz.js'].concat(api.files),
{
ignored: defaultIgnore,
ignoreInitial: true
}
]);
});
test('starts running the initial tests', function (t) {
t.plan(4);
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
start();
t.ok(api.run.calledOnce);
t.same(api.run.firstCall.args, []);
// finish is only called after the run promise fulfils.
t.ok(logger.finish.notCalled);
done();
return delay().then(function () {
t.ok(logger.finish.calledOnce);
});
});
[
{label: 'is added', fire: add, event: 'add'},
{label: 'changes', fire: change, event: 'change'},
{label: 'is removed', fire: unlink, event: 'unlink'}
].forEach(function (variant) {
test('logs a debug message when a file is ' + variant.label, function (t) {
t.plan(2);
start();
variant.fire('file.js');
t.ok(debug.calledOnce);
t.same(debug.firstCall.args, ['ava:watcher', 'Detected %s of %s', variant.event, 'file.js']);
});
});
[
{label: 'is added', fire: add},
{label: 'changes', fire: change},
{label: 'is removed', fire: unlink}
].forEach(function (variant) {
test('reruns initial tests when a source file ' + variant.label, function (t) {
t.plan(6);
api.run.returns(Promise.resolve());
start();
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
// reset isn't called in the initial run.
t.ok(logger.reset.notCalled);
variant.fire();
return debounce().then(function () {
t.ok(api.run.calledTwice);
// reset is called before the second run.
t.ok(logger.reset.calledBefore(api.run.secondCall));
// no explicit files are provided.
t.same(api.run.secondCall.args, []);
// finish is only called after the run promise fulfils.
t.ok(logger.finish.calledOnce);
done();
return delay();
}).then(function () {
t.ok(logger.finish.calledTwice);
});
});
});
test('debounces by 10ms', function (t) {
t.plan(1);
api.run.returns(Promise.resolve());
start();
change();
var before = clock.now;
return debounce().then(function () {
t.is(clock.now - before, 10);
});
});
test('debounces again if changes occur in the interval', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
change();
change();
var before = clock.now;
return debounce(2).then(function () {
t.is(clock.now - before, 2 * 10);
change();
return debounce();
}).then(function () {
t.is(clock.now - before, 3 * 10);
});
});
test('only reruns tests once the initial run has finished', function (t) {
t.plan(2);
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
start();
change();
clock.next();
return delay().then(function () {
t.ok(api.run.calledOnce);
done();
return delay();
}).then(function () {
t.ok(api.run.calledTwice);
});
});
test('only reruns tests once the previous run has finished', function (t) {
t.plan(3);
api.run.returns(Promise.resolve());
start();
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
change();
return debounce().then(function () {
t.ok(api.run.calledTwice);
change();
clock.next();
return delay();
}).then(function () {
t.ok(api.run.calledTwice);
done();
return delay();
}).then(function () {
t.ok(api.run.calledThrice);
});
});
[
{label: 'is added', fire: add},
{label: 'changes', fire: change}
].forEach(function (variant) {
test('(re)runs a test file when it ' + variant.label, function (t) {
t.plan(6);
api.run.returns(Promise.resolve());
start();
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
// reset isn't called in the initial run.
t.ok(logger.reset.notCalled);
variant.fire('test.js');
return debounce().then(function () {
t.ok(api.run.calledTwice);
// reset is called before the second run.
t.ok(logger.reset.calledBefore(api.run.secondCall));
// the test.js file is provided
t.same(api.run.secondCall.args, [['test.js']]);
// finish is only called after the run promise fulfils.
t.ok(logger.finish.calledOnce);
done();
return delay();
}).then(function () {
t.ok(logger.finish.calledTwice);
});
});
});
test('(re)runs several test files when they are added or changed', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
add('test-one.js');
change('test-two.js');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// the test files are provided
t.same(api.run.secondCall.args, [['test-one.js', 'test-two.js']]);
});
});
test('reruns initial tests if both source and test files are added or changed', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
add('test.js');
unlink('source.js');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// no explicit files are provided.
t.same(api.run.secondCall.args, []);
});
});
test('does nothing if tests are deleted', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
unlink('test.js');
return debounce().then(function () {
t.ok(logger.reset.notCalled);
t.ok(api.run.calledOnce);
});
});
test('determines whether changed files are tests based on the initial files patterns', function (t) {
t.plan(2);
api.files = ['foo-{bar,baz}.js'];
api.run.returns(Promise.resolve());
start();
add('foo-bar.js');
add('foo-baz.js');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
t.same(api.run.secondCall.args, [['foo-bar.js', 'foo-baz.js']]);
});
});
test('initial exclude patterns override whether something is a test file', function (t) {
t.plan(2);
api.files = ['foo-{bar,baz}.js'];
api.excludePatterns = ['!*bar*'];
api.run.returns(Promise.resolve());
start();
add('foo-bar.js');
add('foo-baz.js');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// foo-bar.js is excluded from being a test file, thus the initial tests
// are run.
t.same(api.run.secondCall.args, []);
});
});
test('test files must end in .js', function (t) {
t.plan(2);
api.files = ['foo.bar'];
api.run.returns(Promise.resolve());
start();
add('foo.bar');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// foo.bar cannot be a test file, thus the initial tests are run.
t.same(api.run.secondCall.args, []);
});
});
test('test files must not start with an underscore', function (t) {
t.plan(2);
api.files = ['_foo.bar'];
api.run.returns(Promise.resolve());
start();
add('_foo.bar');
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// _foo.bar cannot be a test file, thus the initial tests are run.
t.same(api.run.secondCall.args, []);
});
});
test('files patterns may match directories', function (t) {
t.plan(2);
api.files = ['dir', 'dir2/*/dir3'];
api.run.returns(Promise.resolve());
start();
add(path.join('dir', 'foo.js'));
add(path.join('dir2', 'foo', 'dir3', 'bar.js'));
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
t.same(api.run.secondCall.args, [[path.join('dir', 'foo.js'), path.join('dir2', 'foo', 'dir3', 'bar.js')]]);
});
});
test('exclude patterns override directory matches', function (t) {
t.plan(2);
api.files = ['dir'];
api.excludePatterns = ['!**/exclude/**'];
api.run.returns(Promise.resolve());
start();
add(path.join('dir', 'exclude', 'foo.js'));
return debounce(2).then(function () {
t.ok(api.run.calledTwice);
// dir/exclude/foo.js is excluded from being a test file, thus the initial
// tests are run.
t.same(api.run.secondCall.args, []);
});
});
test('reruns initial tests when "rs" is entered on stdin', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
stdin.write('rs\n');
return delay().then(function () {
t.ok(api.run.calledTwice);
stdin.write('\trs \n');
return delay();
}).then(function () {
t.ok(api.run.calledThrice);
});
});
test('entering "rs" on stdin cancels any debouncing', function (t) {
t.plan(7);
api.run.returns(Promise.resolve());
start();
var before = clock.now;
var done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
add();
stdin.write('rs\n');
return delay().then(function () {
// Processing "rs" caused a new run.
t.ok(api.run.calledTwice);
// Try to advance the clock. This is *after* "rs" was processed. The
// debounce timeout should have been canceled, so the clock can't have
// advanced.
clock.next();
t.is(before, clock.now);
add();
// Advance clock *before* "rs" is received. Note that the previous run
// hasn't finished yet.
clock.next();
stdin.write('rs\n');
return delay();
}).then(function () {
// No new runs yet.
t.ok(api.run.calledTwice);
// Though the clock has advanced.
t.is(clock.now - before, 10);
before = clock.now;
var previous = done;
api.run.returns(new Promise(function (resolve) {
done = resolve;
}));
// Finish the previous run.
previous();
return delay();
}).then(function () {
// There's only one new run.
t.ok(api.run.calledThrice);
stdin.write('rs\n');
return delay();
}).then(function () {
add();
// Finish the previous run. This should cause a new run due to the "rs"
// input.
done();
return delay();
}).then(function () {
// Again there's only one new run.
t.is(api.run.callCount, 4);
// Try to advance the clock. This is *after* "rs" was processed. The
// debounce timeout should have been canceled, so the clock can't have
// advanced.
clock.next();
t.is(before, clock.now);
});
});
test('does nothing if anything other than "rs" is entered on stdin', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
stdin.write('foo\n');
return debounce().then(function () {
t.ok(logger.reset.notCalled);
t.ok(api.run.calledOnce);
});
});
test('ignores unexpected events from chokidar', function (t) {
t.plan(2);
api.run.returns(Promise.resolve());
start();
emitter.emit('all', 'foo');
return debounce().then(function () {
t.ok(logger.reset.notCalled);
t.ok(api.run.calledOnce);
});
});
test('initial run rejects', function (t) {
t.plan(1);
var expected = new Error();
api.run.returns(Promise.reject(expected));
start();
return delay().then(function () {
// The error is rethrown asynchronously, using setImmediate. The clock has
// faked setTimeout, so if we call clock.next() it'll invoke and rethrow
// the error, which can then be caught here.
try {
clock.next();
} catch (err) {
t.is(err, expected);
}
});
});
test('subsequent run rejects', function (t) {
t.plan(1);
api.run.returns(Promise.resolve());
start();
var expected = new Error();
api.run.returns(Promise.reject(expected));
add();
return debounce().then(function () {
// The error is rethrown asynchronously, using setImmediate. The clock has
// faked setTimeout, so if we call clock.next() it'll invoke and rethrow
// the error, which can then be caught here.
try {
clock.next();
} catch (err) {
t.is(err, expected);
}
});
});
return Promise.all(pending);
});
Loading…
Cancel
Save