From f57fe5e1bfb59da73accf11169224030ca8d1894 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Tue, 23 Feb 2016 16:45:54 +0000 Subject: [PATCH 1/5] refactor watcher Use a class-based approach so state can be kept on the instances, reducing the need to pass it through function calls and closures. Extract debouncer into a separate class (kept within the module). Rather than passing process.stdin the CLI is now responsible for starting observation. When 'rs' is entered directly rerun all tests, without passing through the change detection. Reset the logger before every test run, including the first. --- cli.js | 5 +- lib/watcher.js | 235 ++++++++++++++++++++++++++---------------------- test/watcher.js | 32 +++---- 3 files changed, 146 insertions(+), 126 deletions(-) diff --git a/cli.js b/cli.js index 7164983fc..3e100f524 100755 --- a/cli.js +++ b/cli.js @@ -29,7 +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 Watcher = require('./lib/watcher'); var Api = require('./api'); // Bluebird specific @@ -133,7 +133,8 @@ if (files.length === 0) { if (cli.flags.watch) { try { - watcher.start(logger, api, files, arrify(cli.flags.source), process.stdin); + var watcher = new Watcher(logger, api, files, arrify(cli.flags.source)); + watcher.observeStdin(process.stdin); } catch (err) { if (err.name === 'AvaError') { // An AvaError may be thrown if chokidar is not installed. Log it nicely. diff --git a/lib/watcher.js b/lib/watcher.js index 064648497..6a531503c 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -5,7 +5,6 @@ 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 { @@ -23,89 +22,42 @@ function rethrowAsync(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); +function Watcher(logger, api, files, sources) { + this.debouncer = new Debouncer(this); - if (ignored.length === 0) { - ignored = defaultIgnore; - } + this.isTest = makeTestMatcher(files, api.excludePatterns); + this.run = function (specificFiles) { + logger.reset(); + this.busy = api.run(specificFiles || files).then(function () { + logger.finish(); + }, rethrowAsync); + }; - return {paths: paths, ignored: ignored}; + this.dirtyStates = {}; + this.watchFiles(files, sources); + this.rerunAll(); } -exports.start = function (logger, api, files, sources, stdin) { - var isTest = makeTestMatcher(files, api.excludePatterns); - var patterns = getChokidarPatterns(sources, files); +module.exports = Watcher; - var watcher = requireChokidar().watch(patterns.paths, { +Watcher.prototype.watchFiles = function (files, sources) { + var patterns = getChokidarPatterns(files, sources); + + var self = this; + requireChokidar().watch(patterns.paths, { ignored: patterns.ignored, ignoreInitial: true - }); - - var busy = api.run(files).then(function () { - logger.finish(); - }).catch(rethrowAsync); - - var dirtyStates = {}; - watcher.on('all', function (event, path) { + }).on('all', function (event, path) { if (event === 'add' || event === 'change' || event === 'unlink') { debug('Detected %s of %s', event, path); - dirtyStates[path] = event; - debounce(); + self.dirtyStates[path] = event; + self.debouncer.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, files, isTest, dirtyStates); - dirtyStates = {}; - debouncing = null; - debounceAgain = false; - } - }); - }, 10); - } - - function cancelDebounce() { - if (debouncing) { - clearTimeout(debouncing); - debouncing = null; - debounceAgain = false; - } - } +Watcher.prototype.observeStdin = function (stdin) { + var self = this; stdin.resume(); stdin.setEncoding('utf8'); @@ -117,16 +69,117 @@ exports.start = function (logger, api, files, sources, stdin) { // Cancel the debouncer, it might rerun specific tests whereas *all* tests // need to be rerun. - cancelDebounce(); - busy.then(function () { + self.debouncer.cancel(); + self.busy.then(function () { // Cancel the debouncer again, it might have restarted while waiting for // the busy promise to fulfil. - cancelDebounce(); - busy = runAfterChanges(logger, api, files, isTest, {}); + self.debouncer.cancel(); + self.rerunAll(); }); }); }; +Watcher.prototype.rerunAll = function () { + this.dirtyStates = {}; + this.run(); +}; + +Watcher.prototype.runAfterChanges = function () { + var dirtyStates = this.dirtyStates; + this.dirtyStates = {}; + + var dirtyPaths = Object.keys(dirtyStates); + var dirtyTests = dirtyPaths.filter(this.isTest); + var addedOrChangedTests = dirtyTests.filter(function (path) { + return dirtyStates[path] !== 'unlink'; + }); + var unlinkedTests = dirtyTests.filter(function (path) { + return dirtyStates[path] === 'unlink'; + }); + + // Rerun all tests if non-test files were changed. + if (dirtyTests.length !== dirtyPaths.length) { + this.rerunAll(); + return; + } + + // No need to rerun tests if the only change is that tests were deleted. + if (unlinkedTests.length === dirtyPaths.length) { + this.dirtyStates = {}; + return; + } + + // Run any new or changed tests. + this.run(addedOrChangedTests); +}; + +function Debouncer(watcher) { + this.watcher = watcher; + + this.timer = null; + this.repeat = false; +} + +Debouncer.prototype.debounce = function () { + if (this.timer) { + this.again = true; + return; + } + + var self = this; + var timer = this.timer = setTimeout(function () { + self.watcher.busy.then(function () { + // Do nothing if debouncing was canceled while waiting for the busy + // promise to fulfil. + if (self.timer !== timer) { + return; + } + + if (self.again) { + self.timer = null; + self.again = false; + self.debounce(); + } else { + self.watcher.runAfterChanges(); + self.timer = null; + self.again = false; + } + }); + }, 10); +}; + +Debouncer.prototype.cancel = function () { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + this.again = false; + } +}; + +function getChokidarPatterns(files, sources) { + 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(files); + + if (ignored.length === 0) { + ignored = defaultIgnore; + } + + return {paths: paths, ignored: ignored}; +} + function makeTestMatcher(files, excludePatterns) { var initialPatterns = files.concat(excludePatterns); return function (path) { @@ -171,33 +224,3 @@ function makeTestMatcher(files, excludePatterns) { return multimatch(path, recursivePatterns.concat(excludePatterns)).length === 1; }; } - -function runAfterChanges(logger, api, files, 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(files)); - } - }).then(function () { - logger.finish(); - }).catch(rethrowAsync); -} diff --git a/test/watcher.js b/test/watcher.js index a14c9616f..a0276c5e5 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -15,12 +15,12 @@ var setImmediate = require('../lib/globals').setImmediate; test('chokidar is not installed', function (t) { t.plan(2); - var subject = proxyquire.noCallThru().load('../lib/watcher', { + var Subject = proxyquire.noCallThru().load('../lib/watcher', { chokidar: null }); try { - subject.start({}, {excludePatterns: []}, [], []); + new Subject({}, {excludePatterns: []}, [], []); // eslint-disable-line } 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.'); @@ -48,7 +48,7 @@ test('chokidar is installed', function (_t) { ] }; - var subject = proxyquire.noCallThru().load('../lib/watcher', { + var Subject = proxyquire.noCallThru().load('../lib/watcher', { chokidar: chokidar, debug: function (name) { return function () { @@ -93,7 +93,7 @@ test('chokidar is installed', function (_t) { }); var start = function (sources) { - subject.start(logger, api, files, sources || [], stdin); + return new Subject(logger, api, files, sources || []); }; var add = function (path) { @@ -221,14 +221,12 @@ test('chokidar is installed', function (_t) { done = resolve; })); - // reset isn't called in the initial run. - t.ok(logger.reset.notCalled); - variant.fire(); return debounce().then(function () { + t.ok(logger.reset.calledTwice); t.ok(api.run.calledTwice); // reset is called before the second run. - t.ok(logger.reset.calledBefore(api.run.secondCall)); + t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // no explicit files are provided. t.same(api.run.secondCall.args, [files]); @@ -334,14 +332,12 @@ test('chokidar is installed', function (_t) { 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(logger.reset.calledTwice); t.ok(api.run.calledTwice); // reset is called before the second run. - t.ok(logger.reset.calledBefore(api.run.secondCall)); + t.ok(logger.reset.secondCall.calledBefore(api.run.secondCall)); // the test.js file is provided t.same(api.run.secondCall.args, [['test.js']]); @@ -390,7 +386,7 @@ test('chokidar is installed', function (_t) { unlink('test.js'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); @@ -493,7 +489,7 @@ test('chokidar is installed', function (_t) { test('reruns initial tests when "rs" is entered on stdin', function (t) { t.plan(2); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); stdin.write('rs\n'); return delay().then(function () { @@ -509,7 +505,7 @@ test('chokidar is installed', function (_t) { test('entering "rs" on stdin cancels any debouncing', function (t) { t.plan(7); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); var before = clock.now; var done; @@ -581,11 +577,11 @@ test('chokidar is installed', function (_t) { test('does nothing if anything other than "rs" is entered on stdin', function (t) { t.plan(2); api.run.returns(Promise.resolve()); - start(); + start().observeStdin(stdin); stdin.write('foo\n'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); @@ -597,7 +593,7 @@ test('chokidar is installed', function (_t) { emitter.emit('all', 'foo'); return debounce().then(function () { - t.ok(logger.reset.notCalled); + t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); }); }); From f85bcd6014e2860216a7df994f273b2f5605b45c Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 24 Feb 2016 15:15:17 +0000 Subject: [PATCH 2/5] abstract tap grouping in watcher test --- test/watcher.js | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/test/watcher.js b/test/watcher.js index a0276c5e5..6fe0df6ad 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -12,6 +12,27 @@ var test = require('tap').test; var setImmediate = require('../lib/globals').setImmediate; +// Helper to make using beforeEach less arduous. +function group(desc, fn) { + test(desc, function (t) { + var beforeEach = function (fn) { + t.beforeEach(function (done) { + fn(); + done(); + }); + }; + + var pending = []; + var test = function (name, fn) { + pending.push(t.test(name, fn)); + }; + + fn(beforeEach, test); + + return Promise.all(pending); + }); +} + test('chokidar is not installed', function (t) { t.plan(2); @@ -27,7 +48,7 @@ test('chokidar is not installed', function (t) { } }); -test('chokidar is installed', function (_t) { +group('chokidar is installed', function (beforeEach, test) { var chokidar = { watch: sinon.stub() }; @@ -63,7 +84,7 @@ test('chokidar is installed', function (_t) { var emitter; var stdin; var files; - _t.beforeEach(function (done) { + beforeEach(function () { if (clock) { clock.uninstall(); } @@ -88,8 +109,6 @@ test('chokidar is installed', function (_t) { stdin = new PassThrough(); stdin.pause(); - - done(); }); var start = function (sources) { @@ -124,11 +143,6 @@ test('chokidar is installed', function (_t) { }); }; - 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(); @@ -636,6 +650,4 @@ test('chokidar is installed', function (_t) { } }); }); - - return Promise.all(pending); }); From 854d96c392510364c45978e8c7f247db530f1ac3 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 24 Feb 2016 17:53:54 +0000 Subject: [PATCH 3/5] guard against repeated teardowns fork.js sends the teardown command when uncaught exceptions occur, if there are no tests, and when results are received. However the results message is sent from the worker when all tests have run. This means teardown can be sent more than once. --- lib/test-worker.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/test-worker.js b/lib/test-worker.js index f241042e8..6c6020bd1 100644 --- a/lib/test-worker.js +++ b/lib/test-worker.js @@ -122,11 +122,19 @@ process.on('ava-exit', function () { }, delay); }); +var tearingDown = false; process.on('ava-teardown', function () { + // ava-teardown can be sent more than once. + if (tearingDown) { + return; + } + tearingDown = true; + var rejections = loudRejection.currentlyUnhandled(); if (rejections.length === 0) { - return exit(); + exit(); + return; } rejections = rejections.map(function (rejection) { From bf01232fdd8434abe0cfa079e1af29105f75ecc3 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Wed, 24 Feb 2016 12:17:20 +0000 Subject: [PATCH 4/5] only run tests for changed sources Test workers keep track of test dependencies for all registered file extensions. These are sent back to the Api as part of the 'teardown' event, ensuring they're captured irrespective of whether tests passed. The watcher rejects any dependencies that are not source files, matching how Chokidar watches for source file modifications. It maintains a list of source dependencies for each test file. The watcher will find the test files that depend on modified source files and rerun those (along with any other modified test files). If any modified source files cannot be mapped to test files all tests are rerun. This is necessary because only `require()` dependencies are tracked, not file access. --- api.js | 5 + lib/test-worker.js | 16 +- lib/watcher.js | 129 +++++++- package.json | 3 + test/api.js | 31 ++ test/fixture/with-dependencies/dep-1.js | 0 test/fixture/with-dependencies/dep-2.js | 0 test/fixture/with-dependencies/dep-3.custom | 0 test/fixture/with-dependencies/no-tests.js | 3 + .../with-dependencies/require-custom.js | 1 + .../fixture/with-dependencies/test-failure.js | 9 + .../test-uncaught-exception.js | 13 + test/fixture/with-dependencies/test.js | 9 + test/watcher.js | 284 ++++++++++++++++-- 14 files changed, 465 insertions(+), 38 deletions(-) create mode 100644 test/fixture/with-dependencies/dep-1.js create mode 100644 test/fixture/with-dependencies/dep-2.js create mode 100644 test/fixture/with-dependencies/dep-3.custom create mode 100644 test/fixture/with-dependencies/no-tests.js create mode 100644 test/fixture/with-dependencies/require-custom.js create mode 100644 test/fixture/with-dependencies/test-failure.js create mode 100644 test/fixture/with-dependencies/test-uncaught-exception.js create mode 100644 test/fixture/with-dependencies/test.js diff --git a/api.js b/api.js index 944e3e0de..fa5215e24 100644 --- a/api.js +++ b/api.js @@ -64,6 +64,7 @@ Api.prototype._runFile = function (file) { }); return fork(file, options) + .on('teardown', this._handleTeardown) .on('stats', this._handleStats) .on('test', this._handleTest) .on('unhandledRejections', this._handleRejections) @@ -96,6 +97,10 @@ Api.prototype._handleExceptions = function (data) { this.errors.push(err); }; +Api.prototype._handleTeardown = function (data) { + this.emit('dependencies', data.file, data.dependencies); +}; + Api.prototype._handleStats = function (stats) { this.testCount += stats.testCount; }; diff --git a/lib/test-worker.js b/lib/test-worker.js index 6c6020bd1..b0ff31c66 100644 --- a/lib/test-worker.js +++ b/lib/test-worker.js @@ -89,6 +89,17 @@ module.constructor._nodeModulePaths = function () { return ret; }; +var dependencies = []; +Object.keys(require.extensions).forEach(function (ext) { + var wrappedHandler = require.extensions[ext]; + require.extensions[ext] = function (module, filename) { + if (filename !== testPath) { + dependencies.push(filename); + } + wrappedHandler(module, filename); + }; +}); + require(testPath); process.on('uncaughtException', function (exception) { @@ -150,5 +161,8 @@ process.on('ava-teardown', function () { }); function exit() { - send('teardown'); + // Include dependencies in the final teardown message. This ensures the full + // set of dependencies is included no matter how the process exits, unless + // it flat out crashes. + send('teardown', {dependencies: dependencies}); } diff --git a/lib/watcher.js b/lib/watcher.js index 6a531503c..65b53a562 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -2,6 +2,10 @@ var AvaError = require('./ava-error'); var debug = require('debug')('ava:watcher'); +var diff = require('arr-diff'); +var flatten = require('arr-flatten'); +var union = require('array-union'); +var uniq = require('array-uniq'); var defaultIgnore = require('ignore-by-default').directories(); var multimatch = require('multimatch'); var nodePath = require('path'); @@ -33,6 +37,9 @@ function Watcher(logger, api, files, sources) { }, rethrowAsync); }; + this.testDependencies = []; + this.trackTestDependencies(api, sources); + this.dirtyStates = {}; this.watchFiles(files, sources); this.rerunAll(); @@ -56,6 +63,47 @@ Watcher.prototype.watchFiles = function (files, sources) { }); }; +Watcher.prototype.trackTestDependencies = function (api, sources) { + var isSource = makeSourceMatcher(sources); + + var cwd = process.cwd(); + var relative = function (absPath) { + return nodePath.relative(cwd, absPath); + }; + + var self = this; + api.on('dependencies', function (file, dependencies) { + var sourceDeps = dependencies.map(relative).filter(isSource); + self.updateTestDependencies(file, sourceDeps); + }); +}; + +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) { + return dep.file !== file; + }); + return; + } + + var isUpdate = this.testDependencies.some(function (dep) { + if (dep.file === file) { + dep.sources = sources; + return true; + } + }); + + if (!isUpdate) { + this.testDependencies.push(new TestDependency(file, sources)); + } +}; + Watcher.prototype.observeStdin = function (stdin) { var self = this; @@ -90,27 +138,46 @@ Watcher.prototype.runAfterChanges = function () { var dirtyPaths = Object.keys(dirtyStates); var dirtyTests = dirtyPaths.filter(this.isTest); + var dirtySources = diff(dirtyPaths, dirtyTests); var addedOrChangedTests = dirtyTests.filter(function (path) { return dirtyStates[path] !== 'unlink'; }); - var unlinkedTests = dirtyTests.filter(function (path) { - return dirtyStates[path] === 'unlink'; - }); + var unlinkedTests = diff(dirtyTests, addedOrChangedTests); - // Rerun all tests if non-test files were changed. - if (dirtyTests.length !== dirtyPaths.length) { - this.rerunAll(); + this.removeUnlinkedTestDependencies(unlinkedTests); + // No need to rerun tests if the only change is that tests were deleted. + if (unlinkedTests.length === dirtyPaths.length) { return; } - // No need to rerun tests if the only change is that tests were deleted. - if (unlinkedTests.length === dirtyPaths.length) { - this.dirtyStates = {}; + if (dirtySources.length === 0) { + // Run any new or changed tests. + this.run(addedOrChangedTests); + return; + } + + // Try to find tests that depend on the changed source files. + var testsBySource = dirtySources.map(function (path) { + return this.testDependencies.filter(function (dep) { + return dep.contains(path); + }).map(function (dep) { + debug('%s is a dependency of %s', path, dep.file); + return dep.file; + }); + }, this).filter(function (tests) { + return tests.length > 0; + }); + + // Rerun all tests if source files were changed that could not be traced to + // specific tests. + if (testsBySource.length !== dirtySources.length) { + debug('Sources remain that cannot be traced to specific tests. Rerunning all tests'); + this.run(); return; } - // Run any new or changed tests. - this.run(addedOrChangedTests); + // Run all affected tests. + this.run(union(addedOrChangedTests, uniq(flatten(testsBySource)))); }; function Debouncer(watcher) { @@ -180,6 +247,37 @@ function getChokidarPatterns(files, sources) { return {paths: paths, ignored: ignored}; } +function makeSourceMatcher(sources) { + var patterns = sources; + + var hasPositivePattern = patterns.some(function (pattern) { + return pattern[0] !== '!'; + }); + var hasNegativePattern = patterns.some(function (pattern) { + return pattern[0] === '!'; + }); + + // Same defaults as used for Chokidar. + if (!hasPositivePattern) { + patterns = ['package.json', '**/*.js'].concat(patterns); + } + if (!hasNegativePattern) { + patterns = patterns.concat(defaultIgnore.map(function (dir) { + return '!' + dir + '/**/*'; + })); + } + + return function (path) { + // Ignore paths outside the current working directory. They can't be matched + // to a pattern. + if (/^\.\./.test(path)) { + return false; + } + + return multimatch(path, patterns).length === 1; + }; +} + function makeTestMatcher(files, excludePatterns) { var initialPatterns = files.concat(excludePatterns); return function (path) { @@ -224,3 +322,12 @@ function makeTestMatcher(files, excludePatterns) { return multimatch(path, recursivePatterns.concat(excludePatterns)).length === 1; }; } + +function TestDependency(file, sources) { + this.file = file; + this.sources = sources; +} + +TestDependency.prototype.contains = function (source) { + return this.sources.indexOf(source) !== -1; +}; diff --git a/package.json b/package.json index 2eea16a9a..d55ac2061 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,10 @@ "tap" ], "dependencies": { + "arr-diff": "^2.0.0", "arr-flatten": "^1.0.1", + "array-union": "^1.0.1", + "array-uniq": "^1.0.2", "arrify": "^1.0.0", "ava-init": "^0.1.0", "babel-core": "^6.3.21", diff --git a/test/api.js b/test/api.js index 453f14105..4310064dc 100644 --- a/test/api.js +++ b/test/api.js @@ -612,3 +612,34 @@ test('resets state before running', function (t) { t.is(api.passCount, 1); }); }); + +test('emits dependencies for test files', function (t) { + t.plan(8); + + var api = new Api({ + require: [path.resolve('test/fixture/with-dependencies/require-custom.js')] + }); + + api.on('dependencies', function (file, dependencies) { + t.notEqual([ + 'test/fixture/with-dependencies/no-tests.js', + 'test/fixture/with-dependencies/test.js', + 'test/fixture/with-dependencies/test-failure.js', + 'test/fixture/with-dependencies/test-uncaught-exception.js' + ].indexOf(file), -1); + t.same( + dependencies.slice(-3), + [ + path.resolve('test/fixture/with-dependencies/dep-1.js'), + path.resolve('test/fixture/with-dependencies/dep-2.js'), + path.resolve('test/fixture/with-dependencies/dep-3.custom') + ] + ); + }); + + var result = api.run(['test/fixture/with-dependencies/*test*.js']); + + // The test files are designed to cause errors so ignore them here. + api.on('error', function () {}); + result.catch(function () {}); +}); diff --git a/test/fixture/with-dependencies/dep-1.js b/test/fixture/with-dependencies/dep-1.js new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/dep-2.js b/test/fixture/with-dependencies/dep-2.js new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/dep-3.custom b/test/fixture/with-dependencies/dep-3.custom new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixture/with-dependencies/no-tests.js b/test/fixture/with-dependencies/no-tests.js new file mode 100644 index 000000000..e82eb5a65 --- /dev/null +++ b/test/fixture/with-dependencies/no-tests.js @@ -0,0 +1,3 @@ +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; diff --git a/test/fixture/with-dependencies/require-custom.js b/test/fixture/with-dependencies/require-custom.js new file mode 100644 index 000000000..052ac2550 --- /dev/null +++ b/test/fixture/with-dependencies/require-custom.js @@ -0,0 +1 @@ +require.extensions['.custom'] = require.extensions['.js'] diff --git a/test/fixture/with-dependencies/test-failure.js b/test/fixture/with-dependencies/test-failure.js new file mode 100644 index 000000000..9a4cff461 --- /dev/null +++ b/test/fixture/with-dependencies/test-failure.js @@ -0,0 +1,9 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.fail(); +}); diff --git a/test/fixture/with-dependencies/test-uncaught-exception.js b/test/fixture/with-dependencies/test-uncaught-exception.js new file mode 100644 index 000000000..42a05881c --- /dev/null +++ b/test/fixture/with-dependencies/test-uncaught-exception.js @@ -0,0 +1,13 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.pass(); +}); + +setImmediate(() => { + throw new Error('oops'); +}); diff --git a/test/fixture/with-dependencies/test.js b/test/fixture/with-dependencies/test.js new file mode 100644 index 000000000..1fdc1434b --- /dev/null +++ b/test/fixture/with-dependencies/test.js @@ -0,0 +1,9 @@ +import test from '../../../'; + +import './dep-1'; +import './dep-2'; +import './dep-3.custom'; + +test('hey ho', t => { + t.pass(); +}); diff --git a/test/watcher.js b/test/watcher.js index 6fe0df6ad..0708ad654 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -13,25 +13,28 @@ var test = require('tap').test; var setImmediate = require('../lib/globals').setImmediate; // Helper to make using beforeEach less arduous. -function group(desc, fn) { - test(desc, function (t) { - var beforeEach = function (fn) { - t.beforeEach(function (done) { - fn(); - done(); - }); - }; +function makeGroup(test) { + return function group(desc, fn) { + test(desc, function (t) { + var beforeEach = function (fn) { + t.beforeEach(function (done) { + fn(); + done(); + }); + }; - var pending = []; - var test = function (name, fn) { - pending.push(t.test(name, fn)); - }; + var pending = []; + var test = function (name, fn) { + pending.push(t.test(name, fn)); + }; - fn(beforeEach, test); + fn(beforeEach, test, makeGroup(test)); - return Promise.all(pending); - }); + return Promise.all(pending); + }); + }; } +var group = makeGroup(test); test('chokidar is not installed', function (t) { t.plan(2); @@ -41,14 +44,14 @@ test('chokidar is not installed', function (t) { }); try { - new Subject({}, {excludePatterns: []}, [], []); // eslint-disable-line + new Subject({}, {excludePatterns: [], on: function () {}}, [], []); // eslint-disable-line } 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.'); } }); -group('chokidar is installed', function (beforeEach, test) { +group('chokidar is installed', function (beforeEach, test, group) { var chokidar = { watch: sinon.stub() }; @@ -61,12 +64,13 @@ group('chokidar is installed', function (beforeEach, test) { }; var api = { - run: sinon.stub(), excludePatterns: [ '!**/node_modules/**', '!**/fixtures/**', '!**/helpers/**' - ] + ], + on: function () {}, + run: sinon.stub() }; var Subject = proxyquire.noCallThru().load('../lib/watcher', { @@ -81,7 +85,7 @@ group('chokidar is installed', function (beforeEach, test) { }); var clock; - var emitter; + var chokidarEmitter; var stdin; var files; beforeEach(function () { @@ -90,9 +94,9 @@ group('chokidar is installed', function (beforeEach, test) { } clock = lolex.install(0, ['setImmediate', 'setTimeout', 'clearTimeout']); - emitter = new EventEmitter(); + chokidarEmitter = new EventEmitter(); chokidar.watch.reset(); - chokidar.watch.returns(emitter); + chokidar.watch.returns(chokidarEmitter); debug.reset(); @@ -115,14 +119,18 @@ group('chokidar is installed', function (beforeEach, test) { return new Subject(logger, api, files, sources || []); }; + var emitChokidar = function (event, path) { + chokidarEmitter.emit('all', event, path); + }; + var add = function (path) { - emitter.emit('all', 'add', path || 'source.js'); + emitChokidar('add', path || 'source.js'); }; var change = function (path) { - emitter.emit('all', 'change', path || 'source.js'); + emitChokidar('change', path || 'source.js'); }; var unlink = function (path) { - emitter.emit('all', 'unlink', path || 'source.js'); + emitChokidar('unlink', path || 'source.js'); }; var delay = function () { @@ -605,7 +613,7 @@ group('chokidar is installed', function (beforeEach, test) { api.run.returns(Promise.resolve()); start(); - emitter.emit('all', 'foo'); + emitChokidar('foo'); return debounce().then(function () { t.ok(logger.reset.calledOnce); t.ok(api.run.calledOnce); @@ -650,4 +658,228 @@ group('chokidar is installed', function (beforeEach, test) { } }); }); + + group('tracks test dependencies', function (beforeEach, test) { + var apiEmitter; + beforeEach(function () { + apiEmitter = new EventEmitter(); + api.on = function (event, fn) { + apiEmitter.on(event, fn); + }; + }); + + var emitDependencies = function (file, dependencies) { + apiEmitter.emit('dependencies', file, dependencies); + }; + + var seed = function (sources) { + var done; + api.run.returns(new Promise(function (resolve) { + done = resolve; + })); + + var watcher = start(sources); + emitDependencies('test/1.js', [path.resolve('dep-1.js'), path.resolve('dep-3.js')]); + emitDependencies('test/2.js', [path.resolve('dep-2.js'), path.resolve('dep-3.js')]); + + done(); + api.run.returns(new Promise(function () {})); + return watcher; + }; + + test('runs specific tests that depend on changed sources', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/1.js']]); + }); + }); + + test('reruns all tests if a source cannot be mapped to a particular test', function (t) { + t.plan(2); + seed(); + + change('cannot-be-mapped.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [files]); + }); + }); + + test('runs changed tests and tests that depend on changed sources', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + change('test/2.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/2.js', 'test/1.js']]); + }); + }); + + test('avoids duplication when both a test and a source dependency change', function (t) { + t.plan(2); + seed(); + + change('test/1.js'); + change('dep-1.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/1.js']]); + }); + }); + + test('stops tracking unlinked tests', function (t) { + t.plan(2); + seed(); + + unlink('test/1.js'); + change('dep-3.js'); + return debounce(2).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/2.js']]); + }); + }); + + test('updates test dependencies', function (t) { + t.plan(2); + seed(); + + emitDependencies('test/1.js', [path.resolve('dep-4.js')]); + change('dep-4.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/1.js']]); + }); + }); + + [ + { + desc: 'only tracks source dependencies', + sources: ['dep-1.js'] + }, + { + desc: 'exclusion patterns affect tracked source dependencies', + sources: ['!dep-2.js'] + } + ].forEach(function (variant) { + test(variant.desc, function (t) { + t.plan(2); + seed(variant.sources); + + // dep-2.js isn't treated as a source and therefore it's not tracked as + // a dependency for test/2.js. Pretend Chokidar detected a change to + // verify (normally Chokidar would also be ignoring this file but hey). + change('dep-2.js'); + return debounce().then(function () { + 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]); + }); + }); + }); + + test('uses default patterns', function (t) { + t.plan(4); + seed(); + + emitDependencies('test/1.js', [path.resolve('package.json'), path.resolve('index.js'), path.resolve('lib/util.js')]); + emitDependencies('test/2.js', [path.resolve('foo.bar')]); + change('package.json'); + change('index.js'); + change('lib/util.js'); + + api.run.returns(Promise.resolve()); + return debounce(3).then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [['test/1.js']]); + + change('foo.bar'); + return debounce(); + }).then(function () { + 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]); + }); + }); + + test('uses default exclusion patterns if no exclusion pattern is given', function (t) { + t.plan(2); + + // Ensure each directory is treated as containing sources, but rely on + // the default exclusion patterns, also based on these directories, to + // exclude them again. + var sources = defaultIgnore.map(function (dir) { + return dir + '/**/*'; + }); + seed(sources); + + // Synthesize an excluded file for each directory that's ignored by + // default. Apply deeper nesting for each file. + var excludedFiles = defaultIgnore.map(function (dir, index) { + var relPath = dir; + for (var i = index; i >= 0; i--) { + relPath = path.join(relPath, String(i)); + } + return relPath; + }); + + // Ensure test/1.js also depends on the excluded files. + emitDependencies('test/1.js', excludedFiles.map(function (relPath) { + return path.resolve(relPath); + }).concat('dep-1.js')); + + // Modify all excluded files. + excludedFiles.forEach(change); + + return debounce(excludedFiles.length).then(function () { + 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]); + }); + }); + + test('ignores dependencies outside of the current working directory', function (t) { + t.plan(2); + seed(); + + emitDependencies('test/1.js', [path.resolve('../outside.js')]); + // Pretend Chokidar detected a change to verify (normally Chokidar would + // also be ignoring this file but hey). + change('../outside.js'); + return debounce().then(function () { + t.ok(api.run.calledTwice); + t.same(api.run.secondCall.args, [files]); + }); + }); + + test('logs a debug message when a dependent test is found', function (t) { + t.plan(2); + seed(); + + change('dep-1.js'); + return debounce().then(function () { + t.ok(debug.calledTwice); + t.same(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', 'dep-1.js', 'test/1.js']); + }); + }); + + test('logs a debug message when sources remain without dependent tests', function (t) { + t.plan(2); + seed(); + + change('cannot-be-mapped.js'); + return debounce().then(function () { + t.ok(debug.calledTwice); + t.same(debug.secondCall.args, ['ava:watcher', 'Sources remain that cannot be traced to specific tests. Rerunning all tests']); + }); + }); + }); }); From 3ae50429ab7936ca9fb5f785149cc489cf8ff729 Mon Sep 17 00:00:00 2001 From: Mark Wubben Date: Thu, 25 Feb 2016 14:33:43 +0000 Subject: [PATCH 5/5] improve windows path handling Explicitly normalize test file paths as they're discovered in `api.js`. They end up being normalized implicitly in `fork.js` but it's saner if test file paths always use backslashes on Windows. Assume test file and source patterns use slashes. Ensure such when recursing directories in `api.js` and when matching files in `watcher.js`. Fix watcher tests to emit dependencies and chokidar changes using platform-specific paths. --- api.js | 12 ++++++++++-- lib/watcher.js | 19 ++++++++++++++----- package.json | 1 + test/api.js | 29 +++++++++++++++-------------- test/watcher.js | 38 +++++++++++++++++++------------------- 5 files changed, 59 insertions(+), 40 deletions(-) diff --git a/api.js b/api.js index fa5215e24..f31d2d0c6 100644 --- a/api.js +++ b/api.js @@ -13,6 +13,7 @@ var commonPathPrefix = require('common-path-prefix'); var resolveCwd = require('resolve-cwd'); var uniqueTempDir = require('unique-temp-dir'); var findCacheDir = require('find-cache-dir'); +var slash = require('slash'); var AvaError = require('./lib/ava-error'); var fork = require('./lib/fork'); var formatter = require('./lib/enhance-assert').formatter(); @@ -247,10 +248,17 @@ function handlePaths(files, excludePatterns) { return files .map(function (file) { if (fs.statSync(file).isDirectory()) { - return handlePaths([path.join(file, '**', '*.js')], excludePatterns); + var pattern = path.join(file, '**', '*.js'); + if (process.platform === 'win32') { + // Always use / in patterns, harmonizing matching across platforms. + pattern = slash(pattern); + } + return handlePaths([pattern], excludePatterns); } - return file; + // globby returns slashes even on Windows. Normalize here so the file + // paths are consistently platform-accurate as tests are run. + return path.normalize(file); }) .then(flatten) .filter(function (file) { diff --git a/lib/watcher.js b/lib/watcher.js index 65b53a562..244255e32 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -9,6 +9,7 @@ var uniq = require('array-uniq'); var defaultIgnore = require('ignore-by-default').directories(); var multimatch = require('multimatch'); var nodePath = require('path'); +var slash = require('slash'); function requireChokidar() { try { @@ -26,6 +27,12 @@ function rethrowAsync(err) { }); } +// Used on paths before they're passed to multimatch to Harmonize matching +// across platforms. +var matchable = process.platform === 'win32' ? slash : function (path) { + return path; +}; + function Watcher(logger, api, files, sources) { this.debouncer = new Debouncer(this); @@ -274,7 +281,7 @@ function makeSourceMatcher(sources) { return false; } - return multimatch(path, patterns).length === 1; + return multimatch(matchable(path), patterns).length === 1; }; } @@ -287,7 +294,7 @@ function makeTestMatcher(files, excludePatterns) { } // Check if the entire path matches a pattern. - if (multimatch(path, initialPatterns).length === 1) { + if (multimatch(matchable(path), initialPatterns).length === 1) { return true; } @@ -302,7 +309,8 @@ function makeTestMatcher(files, excludePatterns) { var subpaths = dirname.split(nodePath.sep).reduce(function (subpaths, component) { var parent = subpaths[subpaths.length - 1]; if (parent) { - subpaths.push(nodePath.join(parent, component)); + // Always use / to makes multimatch consistent across platforms. + subpaths.push(parent + '/' + component); } else { subpaths.push(component); } @@ -314,12 +322,13 @@ function makeTestMatcher(files, excludePatterns) { var recursivePatterns = subpaths.filter(function (subpath) { return multimatch(subpath, initialPatterns).length === 1; }).map(function (subpath) { - return nodePath.join(subpath, '**', '*.js'); + // Always use / to makes multimatch consistent across platforms. + return 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; + return multimatch(matchable(path), recursivePatterns.concat(excludePatterns)).length === 1; }; } diff --git a/package.json b/package.json index d55ac2061..66a717f5a 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "resolve-cwd": "^1.0.0", "serialize-error": "^1.1.0", "set-immediate-shim": "^1.0.1", + "slash": "^1.0.0", "source-map-support": "^0.4.0", "stack-utils": "^0.4.0", "strip-bom": "^2.0.0", diff --git a/test/api.js b/test/api.js index 4310064dc..1fe220431 100644 --- a/test/api.js +++ b/test/api.js @@ -620,21 +620,22 @@ test('emits dependencies for test files', function (t) { require: [path.resolve('test/fixture/with-dependencies/require-custom.js')] }); + var testFiles = [ + path.normalize('test/fixture/with-dependencies/no-tests.js'), + path.normalize('test/fixture/with-dependencies/test.js'), + path.normalize('test/fixture/with-dependencies/test-failure.js'), + path.normalize('test/fixture/with-dependencies/test-uncaught-exception.js') + ]; + + var sourceFiles = [ + path.resolve('test/fixture/with-dependencies/dep-1.js'), + path.resolve('test/fixture/with-dependencies/dep-2.js'), + path.resolve('test/fixture/with-dependencies/dep-3.custom') + ]; + api.on('dependencies', function (file, dependencies) { - t.notEqual([ - 'test/fixture/with-dependencies/no-tests.js', - 'test/fixture/with-dependencies/test.js', - 'test/fixture/with-dependencies/test-failure.js', - 'test/fixture/with-dependencies/test-uncaught-exception.js' - ].indexOf(file), -1); - t.same( - dependencies.slice(-3), - [ - path.resolve('test/fixture/with-dependencies/dep-1.js'), - path.resolve('test/fixture/with-dependencies/dep-2.js'), - path.resolve('test/fixture/with-dependencies/dep-3.custom') - ] - ); + t.notEqual(testFiles.indexOf(file), -1); + t.same(dependencies.slice(-3), sourceFiles); }); var result = api.run(['test/fixture/with-dependencies/*test*.js']); diff --git a/test/watcher.js b/test/watcher.js index 0708ad654..e2a62a09f 100644 --- a/test/watcher.js +++ b/test/watcher.js @@ -679,8 +679,8 @@ group('chokidar is installed', function (beforeEach, test, group) { })); var watcher = start(sources); - emitDependencies('test/1.js', [path.resolve('dep-1.js'), path.resolve('dep-3.js')]); - emitDependencies('test/2.js', [path.resolve('dep-2.js'), path.resolve('dep-3.js')]); + emitDependencies(path.join('test', '1.js'), [path.resolve('dep-1.js'), path.resolve('dep-3.js')]); + emitDependencies(path.join('test', '2.js'), [path.resolve('dep-2.js'), path.resolve('dep-3.js')]); done(); api.run.returns(new Promise(function () {})); @@ -694,7 +694,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, [['test/1.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); }); }); @@ -714,10 +714,10 @@ group('chokidar is installed', function (beforeEach, test, group) { seed(); change('dep-1.js'); - change('test/2.js'); + change(path.join('test', '2.js')); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [['test/2.js', 'test/1.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '2.js'), path.join('test', '1.js')]]); }); }); @@ -725,11 +725,11 @@ group('chokidar is installed', function (beforeEach, test, group) { t.plan(2); seed(); - change('test/1.js'); + change(path.join('test', '1.js')); change('dep-1.js'); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [['test/1.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); }); }); @@ -737,11 +737,11 @@ group('chokidar is installed', function (beforeEach, test, group) { t.plan(2); seed(); - unlink('test/1.js'); + unlink(path.join('test', '1.js')); change('dep-3.js'); return debounce(2).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [['test/2.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '2.js')]]); }); }); @@ -749,11 +749,11 @@ group('chokidar is installed', function (beforeEach, test, group) { t.plan(2); seed(); - emitDependencies('test/1.js', [path.resolve('dep-4.js')]); + emitDependencies(path.join('test', '1.js'), [path.resolve('dep-4.js')]); change('dep-4.js'); return debounce().then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [['test/1.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); }); }); @@ -788,16 +788,16 @@ group('chokidar is installed', function (beforeEach, test, group) { t.plan(4); seed(); - emitDependencies('test/1.js', [path.resolve('package.json'), path.resolve('index.js'), path.resolve('lib/util.js')]); - emitDependencies('test/2.js', [path.resolve('foo.bar')]); + emitDependencies(path.join('test', '1.js'), [path.resolve('package.json'), path.resolve('index.js'), path.resolve('lib/util.js')]); + emitDependencies(path.join('test', '2.js'), [path.resolve('foo.bar')]); change('package.json'); change('index.js'); - change('lib/util.js'); + change(path.join('lib', 'util.js')); api.run.returns(Promise.resolve()); return debounce(3).then(function () { t.ok(api.run.calledTwice); - t.same(api.run.secondCall.args, [['test/1.js']]); + t.same(api.run.secondCall.args, [[path.join('test', '1.js')]]); change('foo.bar'); return debounce(); @@ -831,7 +831,7 @@ group('chokidar is installed', function (beforeEach, test, group) { }); // Ensure test/1.js also depends on the excluded files. - emitDependencies('test/1.js', excludedFiles.map(function (relPath) { + emitDependencies(path.join('test', '1.js'), excludedFiles.map(function (relPath) { return path.resolve(relPath); }).concat('dep-1.js')); @@ -850,10 +850,10 @@ group('chokidar is installed', function (beforeEach, test, group) { t.plan(2); seed(); - emitDependencies('test/1.js', [path.resolve('../outside.js')]); + emitDependencies(path.join('test', '1.js'), [path.resolve('../outside.js')]); // Pretend Chokidar detected a change to verify (normally Chokidar would // also be ignoring this file but hey). - change('../outside.js'); + change(path.join('..', 'outside.js')); return debounce().then(function () { t.ok(api.run.calledTwice); t.same(api.run.secondCall.args, [files]); @@ -867,7 +867,7 @@ group('chokidar is installed', function (beforeEach, test, group) { change('dep-1.js'); return debounce().then(function () { t.ok(debug.calledTwice); - t.same(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', 'dep-1.js', 'test/1.js']); + t.same(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', 'dep-1.js', path.join('test', '1.js')]); }); });