From b4abee1d534ea53eec8404a0fd63acd384a2b1f4 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 8 Mar 2016 17:52:14 +0000 Subject: [PATCH] make .only sticky in watch mode A second argument can be passed to Api#run(). If true the tests will be run in exclusive mode, regardless of whether exclusive tests are detected. The Api now remits the 'stats' event from the forks. The watcher keeps track of exclusive tests. If all test files that contained exclusive tests need to be rerun it runs them without forcing exclusive mode. This means the exclusivity is determined by the tests themselves. If a test file, containing exclusive tests, is not one of the files being rerun, it forces exclusive mode. This ensures only exclusive tests are run in the changed files, making .only sticky. If all test files that contained exclusive tests are removed, sticky mode is disabled. The same happens if there are no more exclusive tests after a run. Fixes #593. --- api.js | 9 ++- lib/watcher.js | 49 +++++++++++++--- test/api.js | 35 +++++++++++ test/watcher.js | 151 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 213 insertions(+), 31 deletions(-) diff --git a/api.js b/api.js index 6c46a5cd6..075a7229c 100644 --- a/api.js +++ b/api.js @@ -106,6 +106,8 @@ Api.prototype._handleTeardown = function (data) { }; Api.prototype._handleStats = function (stats) { + this.emit('stats', stats); + if (this.hasExclusive && !stats.hasExclusive) { return; } @@ -165,10 +167,15 @@ Api.prototype._prefixTitle = function (file) { return prefix; }; -Api.prototype.run = function (files) { +Api.prototype.run = function (files, options) { var self = this; this._reset(); + + if (options && options.runOnlyExclusive) { + this.hasExclusive = true; + } + return handlePaths(files, this.excludePatterns) .map(function (file) { return path.resolve(file); diff --git a/lib/watcher.js b/lib/watcher.js index 2a2a41fef..9398cadb4 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -39,7 +39,19 @@ function Watcher(logger, api, files, sources) { this.isTest = makeTestMatcher(files, api.excludePatterns); this.run = function (specificFiles) { logger.reset(); - this.busy = api.run(specificFiles || files).then(function () { + + var runOnlyExclusive = false; + if (specificFiles) { + var exclusiveFiles = specificFiles.filter(function (file) { + return this.filesWithExclusiveTests.indexOf(file) !== -1; + }, this); + + runOnlyExclusive = exclusiveFiles.length !== this.filesWithExclusiveTests.length; + } + + this.busy = api.run(specificFiles || files, { + runOnlyExclusive: runOnlyExclusive + }).then(function () { logger.finish(); }, rethrowAsync); }; @@ -47,6 +59,9 @@ function Watcher(logger, api, files, sources) { this.testDependencies = []; this.trackTestDependencies(api, sources); + this.filesWithExclusiveTests = []; + this.trackExclusivity(api); + this.dirtyStates = {}; this.watchFiles(files, sources); this.rerunAll(); @@ -85,12 +100,6 @@ Watcher.prototype.trackTestDependencies = function (api, sources) { }); }; -Watcher.prototype.removeUnlinkedTestDependencies = function (unlinkedTests) { - unlinkedTests.forEach(function (testFile) { - this.updateTestDependencies(testFile, []); - }, this); -}; - Watcher.prototype.updateTestDependencies = function (file, sources) { if (sources.length === 0) { this.testDependencies = this.testDependencies.filter(function (dep) { @@ -113,6 +122,30 @@ Watcher.prototype.updateTestDependencies = function (file, sources) { } }; +Watcher.prototype.trackExclusivity = function (api) { + var self = this; + api.on('stats', function (stats) { + self.updateExclusivity(stats.file, stats.hasExclusive); + }); +}; + +Watcher.prototype.updateExclusivity = function (file, hasExclusiveTests) { + var index = this.filesWithExclusiveTests.indexOf(file); + + if (hasExclusiveTests && index === -1) { + this.filesWithExclusiveTests.push(file); + } else if (!hasExclusiveTests && index !== -1) { + this.filesWithExclusiveTests.splice(index, 1); + } +}; + +Watcher.prototype.cleanUnlinkedTests = function (unlinkedTests) { + unlinkedTests.forEach(function (testFile) { + this.updateTestDependencies(testFile, []); + this.updateExclusivity(testFile, false); + }, this); +}; + Watcher.prototype.observeStdin = function (stdin) { var self = this; @@ -153,7 +186,7 @@ Watcher.prototype.runAfterChanges = function () { }); var unlinkedTests = diff(dirtyTests, addedOrChangedTests); - this.removeUnlinkedTestDependencies(unlinkedTests); + this.cleanUnlinkedTests(unlinkedTests); // No need to rerun tests if the only change is that tests were deleted. if (unlinkedTests.length === dirtyPaths.length) { return; diff --git a/test/api.js b/test/api.js index a2fdb4011..d105444df 100644 --- a/test/api.js +++ b/test/api.js @@ -621,6 +621,21 @@ test('test file with exclusive tests causes non-exclusive tests in other files t }); }); +test('test files can be forced to run in exclusive mode', function (t) { + t.plan(4); + + var api = new Api(); + return api.run( + [path.join(__dirname, 'fixture/es2015.js')], + {runOnlyExclusive: true} + ).then(function () { + t.ok(api.hasExclusive); + t.is(api.testCount, 0); + t.is(api.passCount, 0); + t.is(api.failCount, 0); + }); +}); + test('resets state before running', function (t) { t.plan(2); @@ -666,6 +681,26 @@ test('emits dependencies for test files', function (t) { result.catch(function () {}); }); +test('emits stats for test files', function (t) { + t.plan(2); + + var api = new Api(); + api.on('stats', function (stats) { + if (stats.file === path.normalize('test/fixture/exclusive.js')) { + t.is(stats.hasExclusive, true); + } else if (stats.file === path.normalize('test/fixture/generators.js')) { + t.is(stats.hasExclusive, false); + } else { + t.ok(false); + } + }); + + return api.run([ + 'test/fixture/exclusive.js', + 'test/fixture/generators.js' + ]); +}); + test('verify test count', function (t) { t.plan(8); diff --git a/test/watcher.js b/test/watcher.js index b0df282da..fdcc20607 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -203,7 +203,7 @@ group('chokidar is installed', function (beforeEach, test, group) { start(); t.ok(api.run.calledOnce); - t.same(api.run.firstCall.args, [files]); + t.same(api.run.firstCall.args, [files, {runOnlyExclusive: false}]); // finish is only called after the run promise fulfils. t.ok(logger.finish.notCalled); @@ -250,7 +250,7 @@ group('chokidar is installed', function (beforeEach, test, group) { // reset is called before the second run. t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // no explicit files are provided. - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); // finish is only called after the run promise fulfils. t.ok(logger.finish.calledOnce); @@ -361,7 +361,7 @@ group('chokidar is installed', function (beforeEach, test, group) { // reset is called before the second run. t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // the test.js file is provided - t.same(api.run.secondCall.args, [['test.js']]); + t.same(api.run.secondCall.args, [['test.js'], {runOnlyExclusive: false}]); // finish is only called after the run promise fulfils. t.ok(logger.finish.calledOnce); @@ -383,7 +383,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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']]); + t.same(api.run.secondCall.args, [['test-one.js', 'test-two.js'], {runOnlyExclusive: false}]); }); }); @@ -397,7 +397,7 @@ group('chokidar is installed', function (beforeEach, test, group) { return debounce(2).then(function () { t.ok(api.run.calledTwice); // no explicit files are provided. - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -424,7 +424,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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']]); + t.same(api.run.secondCall.args, [['foo-bar.js', 'foo-baz.js'], {runOnlyExclusive: false}]); }); }); @@ -442,7 +442,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -457,7 +457,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -472,7 +472,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -487,7 +487,10 @@ group('chokidar is installed', function (beforeEach, test, group) { 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')]]); + t.same(api.run.secondCall.args, [ + [path.join('dir', 'foo.js'), path.join('dir2', 'foo', 'dir3', 'bar.js')], + {runOnlyExclusive: false} + ]); }); }); @@ -504,7 +507,7 @@ group('chokidar is installed', function (beforeEach, test, group) { 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, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -696,7 +699,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('dep-1.js'); return debounce().then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')], {runOnlyExclusive: false}]); }); }); @@ -707,7 +710,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('cannot-be-mapped.js'); return debounce().then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -719,7 +722,10 @@ group('chokidar is installed', function (beforeEach, test, group) { change(path.join('test', '2.js')); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '2.js'), path.join('test', '1.js')]]); + t.same(api.run.secondCall.args, [ + [path.join('test', '2.js'), path.join('test', '1.js')], + {runOnlyExclusive: false} + ]); }); }); @@ -731,7 +737,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('dep-1.js'); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')], {runOnlyExclusive: false}]); }); }); @@ -743,7 +749,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('dep-3.js'); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '2.js')]]); + t.same(api.run.secondCall.args, [[path.join('test', '2.js')], {runOnlyExclusive: false}]); }); }); @@ -755,7 +761,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('dep-4.js'); return debounce().then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')], {runOnlyExclusive: false}]); }); }); @@ -781,7 +787,7 @@ group('chokidar is installed', function (beforeEach, test, group) { t.ok(api.run.calledTwice); // Expect all tests to be rerun since dep-2.js is not a tracked // dependency. - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); }); @@ -799,7 +805,7 @@ group('chokidar is installed', function (beforeEach, test, group) { api.run.returns(Promise.resolve()); return debounce(3).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')], {runOnlyExclusive: false}]); change('foo.bar'); return debounce(); @@ -807,7 +813,7 @@ group('chokidar is installed', function (beforeEach, test, group) { t.ok(api.run.calledThrice); // Expect all tests to be rerun since foo.bar is not a tracked // dependency. - t.same(api.run.thirdCall.args, [files]); + t.same(api.run.thirdCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -844,7 +850,7 @@ group('chokidar is installed', function (beforeEach, test, group) { t.ok(api.run.calledTwice); // Since the excluded files are not tracked as a dependency, all tests // are expected to be rerun. - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -858,7 +864,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change(path.join('..', 'outside.js')); return debounce().then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [files]); + t.same(api.run.secondCall.args, [files, {runOnlyExclusive: false}]); }); }); @@ -884,4 +890,105 @@ group('chokidar is installed', function (beforeEach, test, group) { }); }); }); + + group('.only is sticky', function (beforeEach, test) { + var apiEmitter; + beforeEach(function () { + apiEmitter = new EventEmitter(); + api.on = function (event, fn) { + apiEmitter.on(event, fn); + }; + }); + + var emitStats = function (file, hasExclusive) { + apiEmitter.emit('stats', {file: file, hasExclusive: hasExclusive}); + }; + + var t1 = path.join('test', '1.js'); + var t2 = path.join('test', '2.js'); + var t3 = path.join('test', '3.js'); + var t4 = path.join('test', '4.js'); + + var seed = function () { + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + var watcher = start(); + emitStats(t1, true); + emitStats(t2, true); + emitStats(t3, false); + emitStats(t4, false); + + done(); + api.run.returns(new Promise(function () {})); + return watcher; + }; + + test('changed test files (none of which previously contained .only) are run in exclusive mode', function (t) { + t.plan(2); + seed(); + + change(t3); + change(t4); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[t3, t4], {runOnlyExclusive: true}]); + }); + }); + + test('changed test files (comprising some, but not all, files that previously contained .only) are run in exclusive mode', function (t) { + t.plan(2); + seed(); + + change(t1); + change(t4); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[t1, t4], {runOnlyExclusive: true}]); + }); + }); + + test('changed test files (comprising all files that previously contained .only) are run in regular mode', function (t) { + t.plan(2); + seed(); + + change(t1); + change(t2); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[t1, t2], {runOnlyExclusive: false}]); + }); + }); + + test('once no test files contain .only, further changed test files are run in regular mode', function (t) { + t.plan(2); + seed(); + + emitStats(t1, false); + emitStats(t2, false); + + change(t3); + change(t4); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[t3, t4], {runOnlyExclusive: false}]); + }); + }); + + test('once test files containing .only are removed, further changed test files are run in regular mode', function (t) { + t.plan(2); + seed(); + + unlink(t1); + unlink(t2); + change(t3); + change(t4); + return debounce(4).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [[t3, t4], {runOnlyExclusive: false}]); + }); + }); + }); });