/* eslint-disable promise/no-callback-in-promise */
'use strict';
const path = require('path');
const EventEmitter = require('events');
const PassThrough = require('stream').PassThrough;
const defaultIgnore = require('ignore-by-default').directories();
const lolex = require('lolex');
const proxyquire = require('proxyquire');
const sinon = require('sinon');
const test = require('tap').test;
const AvaFiles = require('../lib/ava-files');

const setImmediate = require('../lib/now-and-timers').setImmediate;

// Helper to make using beforeEach less arduous
function makeGroup(test) {
	return (desc, fn) => {
		test(desc, t => {
			const beforeEach = fn => {
				t.beforeEach(done => {
					fn();
					done();
				});
			};

			const pending = [];
			const test = (name, fn) => {
				pending.push(t.test(name, fn));
			};

			fn(beforeEach, test, makeGroup(test));

			return Promise.all(pending);
		});
	};
}
const group = makeGroup(test);

group('chokidar', (beforeEach, test, group) => {
	let chokidar;
	let debug;
	let reporter;
	let api;
	let avaFiles;
	let Subject;
	let runStatus;
	let resetRunStatus;
	let clock;
	let chokidarEmitter;
	let stdin;
	let files;
	let defaultApiOptions;

	function proxyWatcher(opts) {
		return proxyquire.noCallThru().load('../lib/watcher', opts ||
			{
				chokidar,
				debug(name) {
					return function () {
						const args = [name];
						args.push.apply(args, arguments);
						debug.apply(null, args);
					};
				},
				'./ava-files': avaFiles
			});
	}

	beforeEach(() => {
		chokidar = {
			watch: sinon.stub()
		};

		debug = sinon.spy();

		reporter = {
			endRun: sinon.spy()
		};

		api = {
			on() {},
			run: sinon.stub()
		};

		resetRunStatus = () => {
			runStatus = {
				stats: {
					byFile: new Map(),
					declaredTests: 0,
					failedHooks: 0,
					failedTests: 0,
					failedWorkers: 0,
					files,
					finishedWorkers: 0,
					internalErrors: 0,
					remainingTests: 0,
					passedKnownFailingTests: 0,
					passedTests: 0,
					selectedTests: 0,
					skippedTests: 0,
					timeouts: 0,
					todoTests: 0,
					uncaughtExceptions: 0,
					unhandledRejections: 0
				}
			};

			return runStatus;
		};

		if (clock) {
			clock.uninstall();
		}

		clock = lolex.install({
			toFake: [
				'setImmediate',
				'setTimeout',
				'clearTimeout'
			]
		});

		chokidarEmitter = new EventEmitter();
		chokidar.watch.returns(chokidarEmitter);

		avaFiles = AvaFiles;

		api.run.returns(new Promise(() => {}));
		files = [
			'test.js',
			'test-*.js',
			'test'
		];
		defaultApiOptions = {
			clearLogOnNextRun: false,
			previousFailures: 0,
			runOnlyExclusive: false,
			runVector: 1,
			updateSnapshots: false
		};

		resetRunStatus();

		stdin = new PassThrough();
		stdin.pause();

		Subject = proxyWatcher();
	});

	const start = (specificFiles, sources) => new Subject(reporter, api, specificFiles || files, sources || []);

	const emitChokidar = (event, path) => {
		chokidarEmitter.emit('all', event, path);
	};

	const add = path => {
		emitChokidar('add', path || 'source.js');
	};
	const change = path => {
		emitChokidar('change', path || 'source.js');
	};
	const unlink = path => {
		emitChokidar('unlink', path || 'source.js');
	};

	const delay = () => new Promise(resolve => {
		setImmediate(resolve);
	});

	// Advance the clock to get past the debounce timeout, then wait for a promise
	// to be resolved to get past the `busy.then()` delay
	const debounce = times => {
		times = times >= 0 ? times : 1;
		clock.next();
		return delay().then(() => {
			if (times > 1) {
				return debounce(times - 1);
			}
		});
	};

	test('watches for default source file changes, as well as test files', t => {
		t.plan(2);
		start();

		t.ok(chokidar.watch.calledOnce);
		t.strictDeepEqual(chokidar.watch.firstCall.args, [
			['package.json', '**/*.js', '**/*.snap'].concat(files),
			{
				ignored: defaultIgnore.map(dir => `${dir}/**/*`),
				ignoreInitial: true
			}
		]);
	});

	test('watched source files are configurable', t => {
		t.plan(2);
		start(null, ['foo.js', '!bar.js', 'baz.js', '!qux.js']);

		t.ok(chokidar.watch.calledOnce);
		t.strictDeepEqual(chokidar.watch.firstCall.args, [
			['foo.js', 'baz.js'].concat(files),
			{
				ignored: defaultIgnore.map(dir => `${dir}/**/*`).concat('bar.js', 'qux.js'),
				ignoreInitial: true
			}
		]);
	});

	test('configured sources can override default ignore patterns', t => {
		t.plan(2);
		start(null, ['node_modules/foo/*.js']);

		t.ok(chokidar.watch.calledOnce);
		t.strictDeepEqual(chokidar.watch.firstCall.args, [
			['node_modules/foo/*.js'].concat(files),
			{
				ignored: defaultIgnore.map(dir => `${dir}/**/*`).concat('!node_modules/foo/*.js'),
				ignoreInitial: true
			}
		]);
	});

	test('starts running the initial tests', t => {
		t.plan(4);

		let done;
		api.run.returns(new Promise(resolve => {
			done = () => {
				resolve(runStatus);
			};
		}));

		start();
		t.ok(api.run.calledOnce);
		t.strictDeepEqual(api.run.firstCall.args, [files, defaultApiOptions]);

		// The endRun method is only called after the run promise fulfils
		t.ok(reporter.endRun.notCalled);
		done();
		return delay().then(() => {
			t.ok(reporter.endRun.calledOnce);
		});
	});

	[
		{
			label: 'is added',
			fire: add,
			event: 'add'
		},
		{
			label: 'changes',
			fire: change,
			event: 'change'
		},
		{
			label: 'is removed',
			fire: unlink,
			event: 'unlink'
		}
	].forEach(variant => {
		test(`logs a debug message when a file is ${variant.label}`, t => {
			t.plan(2);
			start();

			variant.fire('file.js');
			t.ok(debug.calledOnce);
			t.strictDeepEqual(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(variant => {
		test(`reruns initial tests when a source file ${variant.label}`, t => {
			t.plan(4);

			api.run.returns(Promise.resolve(runStatus));
			start();

			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			variant.fire();
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				// No explicit files are provided
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);

				// Finish is only called after the run promise fulfils
				t.ok(reporter.endRun.calledOnce);

				resetRunStatus();
				done();
				return delay();
			}).then(() => {
				t.ok(reporter.endRun.calledTwice);
			});
		});
	});

	[
		{
			label: 'failures',
			prop: 'failedTests'
		},
		{
			label: 'rejections',
			prop: 'unhandledRejections'
		},
		{
			label: 'exceptions',
			prop: 'uncaughtExceptions'
		}
	].forEach(variant => {
		test(`does not clear log if the previous run had ${variant.label}`, t => {
			t.plan(2);

			runStatus.stats[variant.prop] = 1;
			api.run.returns(Promise.resolve(runStatus));
			start();

			api.run.returns(Promise.resolve(resetRunStatus()));
			change();
			return debounce().then(() => {
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: false,
					runVector: 2
				})]);

				change();
				return debounce();
			}).then(() => {
				t.strictDeepEqual(api.run.thirdCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 3
				})]);
			});
		});
	});

	test('debounces by 100ms', t => {
		t.plan(1);
		api.run.returns(Promise.resolve(runStatus));
		start();

		change();
		const before = clock.now;
		return debounce().then(() => {
			t.is(clock.now - before, 100);
		});
	});

	test('debounces again if changes occur in the interval', t => {
		t.plan(4);
		api.run.returns(Promise.resolve(runStatus));
		start();

		change();
		change();

		const before = clock.now;
		return debounce().then(() => {
			change();
			return debounce();
		}).then(() => {
			t.is(clock.now - before, 150);
			change();
			return debounce();
		}).then(() => {
			t.is(clock.now - before, 175);
			change();
			return debounce();
		}).then(() => {
			t.is(clock.now - before, 187);
			change();
			return debounce();
		}).then(() => {
			t.is(clock.now - before, 197);
		});
	});

	test('only reruns tests once the initial run has finished', t => {
		t.plan(2);

		let done;
		api.run.returns(new Promise(resolve => {
			done = () => {
				resolve(runStatus);
			};
		}));
		start();

		change();
		clock.next();
		return delay().then(() => {
			t.ok(api.run.calledOnce);

			done();
			return delay();
		}).then(() => {
			t.ok(api.run.calledTwice);
		});
	});

	test('only reruns tests once the previous run has finished', t => {
		t.plan(3);
		api.run.returns(Promise.resolve(runStatus));
		start();

		let done;
		api.run.returns(new Promise(resolve => {
			done = () => {
				resolve(runStatus);
			};
		}));

		change();
		return debounce().then(() => {
			t.ok(api.run.calledTwice);

			change();
			clock.next();
			return delay();
		}).then(() => {
			t.ok(api.run.calledTwice);

			done();
			return delay();
		}).then(() => {
			t.ok(api.run.calledThrice);
		});
	});

	[
		{
			label: 'is added',
			fire: add
		},
		{
			label: 'changes',
			fire: change
		}
	].forEach(variant => {
		test(`(re)runs a test file when it ${variant.label}`, t => {
			t.plan(4);

			api.run.returns(Promise.resolve(runStatus));
			start();

			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			variant.fire('test.js');
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				// The `test.js` file is provided
				t.strictDeepEqual(api.run.secondCall.args, [['test.js'], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);

				// The endRun method is only called after the run promise fulfills
				t.ok(reporter.endRun.calledOnce);

				resetRunStatus();
				done();
				return delay();
			}).then(() => {
				t.ok(reporter.endRun.calledTwice);
			});
		});
	});

	test('(re)runs several test files when they are added or changed', t => {
		t.plan(2);
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('test-one.js');
		change('test-two.js');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// The test files are provided
			t.strictDeepEqual(api.run.secondCall.args, [['test-one.js', 'test-two.js'], Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('reruns initial tests if both source and test files are added or changed', t => {
		t.plan(2);
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('test.js');
		unlink('source.js');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// No explicit files are provided
			t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('does nothing if tests are deleted', t => {
		t.plan(1);
		api.run.returns(Promise.resolve(runStatus));
		start();

		unlink('test.js');
		return debounce().then(() => {
			t.ok(api.run.calledOnce);
		});
	});

	test('determines whether changed files are tests based on the initial files patterns', t => {
		t.plan(2);

		files = ['foo-{bar,baz}.js'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('foo-bar.js');
		add('foo-baz.js');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			t.strictDeepEqual(api.run.secondCall.args, [['foo-bar.js', 'foo-baz.js'], Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('initial exclude patterns override whether something is a test file', t => {
		t.plan(2);

		avaFiles = function (options) {
			const ret = new AvaFiles(options);
			// Note: There is no way for users to actually set exclude patterns yet.
			// This test just validates that internal updates to the default excludes pattern will be obeyed.
			ret.excludePatterns = ['!*bar*'];
			return ret;
		};
		Subject = proxyWatcher();

		files = ['foo-{bar,baz}.js'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('foo-bar.js');
		add('foo-baz.js');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// `foo-bar.js` is excluded from being a test file, thus the initial tests
			// are run
			t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('test files must end in .js', t => {
		t.plan(2);

		files = ['foo.bar'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('foo.bar');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// `foo.bar` cannot be a test file, thus the initial tests are run
			t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('test files must not start with an underscore', t => {
		t.plan(2);

		api.files = ['_foo.bar'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add('_foo.bar');
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// `_foo.bar` cannot be a test file, thus the initial tests are run
			t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	test('files patterns may match directories', t => {
		t.plan(2);

		files = ['dir', 'another-dir/*/deeper'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add(path.join('dir', 'test.js'));
		add(path.join('dir', 'nested', 'test.js'));
		add(path.join('another-dir', 'nested', 'deeper', 'test.js'));
		return debounce(3).then(() => {
			t.ok(api.run.calledTwice);
			t.strictDeepEqual(api.run.secondCall.args, [
				[
					path.join('dir', 'test.js'),
					path.join('dir', 'nested', 'test.js'),
					path.join('another-dir', 'nested', 'deeper', 'test.js')
				],
				Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})
			]);
		});
	});

	test('exclude patterns override directory matches', t => {
		t.plan(2);

		avaFiles = function (options) {
			const ret = new AvaFiles(options);
			// Note: There is no way for users to actually set exclude patterns yet.
			// This test just validates that internal updates to the default excludes pattern will be obeyed.
			ret.excludePatterns = ['!**/exclude/**'];
			return ret;
		};

		Subject = proxyWatcher();

		files = ['dir'];
		api.run.returns(Promise.resolve(runStatus));
		start();

		add(path.join('dir', 'exclude', 'foo.js'));
		return debounce(2).then(() => {
			t.ok(api.run.calledTwice);
			// `dir/exclude/foo.js` is excluded from being a test file, thus the initial
			// tests are run
			t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
				clearLogOnNextRun: true,
				runVector: 2
			})]);
		});
	});

	['r', 'rs'].forEach(input => {
		test(`reruns initial tests when "${input}" is entered on stdin`, t => {
			t.plan(4);
			api.run.returns(Promise.resolve(runStatus));
			start().observeStdin(stdin);

			stdin.write(`${input}\n`);
			return delay().then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					runVector: 2
				})]);

				stdin.write(`\t${input}  \n`);
				return delay();
			}).then(() => {
				t.ok(api.run.calledThrice);
				t.strictDeepEqual(api.run.thirdCall.args, [files, Object.assign({}, defaultApiOptions, {
					runVector: 3
				})]);
			});
		});
	});

	test(`reruns previous tests and update snapshots when "u" is entered on stdin`, t => {
		const options = Object.assign({}, defaultApiOptions, {updateSnapshots: true});
		const previousFiles = ['test.js'];
		t.plan(4);
		api.run.returns(Promise.resolve(runStatus));
		start(previousFiles).observeStdin(stdin);

		stdin.write(`u\n`);
		return delay().then(() => {
			t.ok(api.run.calledTwice);
			t.strictDeepEqual(api.run.secondCall.args, [previousFiles, Object.assign({}, options, {
				runVector: 2
			})]);

			stdin.write(`\tu  \n`);
			return delay();
		}).then(() => {
			t.ok(api.run.calledThrice);
			t.strictDeepEqual(api.run.thirdCall.args, [previousFiles, Object.assign({}, options, {
				runVector: 3
			})]);
		});
	});

	['r', 'rs', 'u'].forEach(input => {
		test(`entering "${input}" on stdin prevents the log from being cleared`, t => {
			t.plan(2);
			api.run.returns(Promise.resolve(runStatus));
			start().observeStdin(stdin);

			stdin.write(`${input}\n`);
			return delay().then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: false,
					runVector: 2,
					updateSnapshots: input === 'u'
				})]);
			});
		});

		test(`entering "${input}" on stdin cancels any debouncing`, t => {
			t.plan(7);
			api.run.returns(Promise.resolve(runStatus));
			start().observeStdin(stdin);

			let before = clock.now;
			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			add();
			stdin.write(`${input}\n`);
			return delay().then(() => {
				// Processing "rs" caused a new run
				t.ok(api.run.calledTwice);

				// Try to advance the clock. This is *after* input 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* input is received. Note that the previous run
				// hasn't finished yet.
				clock.next();
				stdin.write(`${input}\n`);

				return delay();
			}).then(() => {
				// No new runs yet
				t.ok(api.run.calledTwice);
				// Though the clock has advanced
				t.is(clock.now - before, 100);
				before = clock.now;

				const previous = done;
				api.run.returns(new Promise(resolve => {
					done = () => {
						resolve(runStatus);
					};
				}));

				// Finish the previous run
				previous();

				return delay();
			}).then(() => {
				// There's only one new run
				t.ok(api.run.calledThrice);

				stdin.write(`${input}\n`);
				return delay();
			}).then(() => {
				add();

				// Finish the previous run. This should cause a new run due to the
				// input.
				done();

				return delay();
			}).then(() => {
				// Again there's only one new run
				t.is(api.run.callCount, 4);

				// Try to advance the clock. This is *after* input 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', t => {
		t.plan(1);
		api.run.returns(Promise.resolve(runStatus));
		start().observeStdin(stdin);

		stdin.write('foo\n');
		return debounce().then(() => {
			t.ok(api.run.calledOnce);
		});
	});

	test('ignores unexpected events from chokidar', t => {
		t.plan(1);
		api.run.returns(Promise.resolve(runStatus));
		start();

		emitChokidar('foo');
		return debounce().then(() => {
			t.ok(api.run.calledOnce);
		});
	});

	test('initial run rejects', t => {
		t.plan(1);
		const expected = new Error();
		api.run.returns(Promise.reject(expected));
		start();

		return delay().then(() => {
			// 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', t => {
		t.plan(1);
		api.run.returns(Promise.resolve(runStatus));
		start();

		const expected = new Error();
		api.run.returns(Promise.reject(expected));

		add();
		return debounce().then(() => {
			// 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);
			}
		});
	});

	group('tracks test dependencies', (beforeEach, test) => {
		let apiEmitter;
		let runStatus;
		let runStatusEmitter;
		beforeEach(() => {
			apiEmitter = new EventEmitter();
			api.on = (event, fn) => {
				apiEmitter.on(event, fn);
			};
			runStatusEmitter = new EventEmitter();
			runStatus = {
				stats: {
					byFile: new Map(),
					declaredTests: 0,
					failedHooks: 0,
					failedTests: 0,
					failedWorkers: 0,
					files,
					finishedWorkers: 0,
					internalErrors: 0,
					remainingTests: 0,
					passedKnownFailingTests: 0,
					passedTests: 0,
					selectedTests: 0,
					skippedTests: 0,
					timeouts: 0,
					todoTests: 0,
					uncaughtExceptions: 0,
					unhandledRejections: 0
				},
				on(event, fn) {
					runStatusEmitter.on(event, fn);
				}
			};
		});

		const emitDependencies = (testFile, dependencies) => {
			runStatusEmitter.emit('stateChange', {type: 'dependencies', testFile, dependencies});
		};

		const seed = sources => {
			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			const watcher = start(null, sources);
			const files = [path.join('test', '1.js'), path.join('test', '2.js')];
			const absFiles = files.map(relFile => path.resolve(relFile));
			apiEmitter.emit('run', {
				files: absFiles,
				status: runStatus
			});
			emitDependencies(files[0], [path.resolve('dep-1.js'), path.resolve('dep-3.js')]);
			emitDependencies(files[1], [path.resolve('dep-2.js'), path.resolve('dep-3.js')]);

			done();
			api.run.returns(new Promise(() => {}));
			return watcher;
		};

		test('runs specific tests that depend on changed sources', t => {
			t.plan(2);
			seed();

			change('dep-1.js');
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '1.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('reruns all tests if a source cannot be mapped to a particular test', t => {
			t.plan(2);
			seed();

			change('cannot-be-mapped.js');
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('runs changed tests and tests that depend on changed sources', t => {
			t.plan(2);
			seed();

			change('dep-1.js');
			change(path.join('test', '2.js'));
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [
					[path.join('test', '2.js'), path.join('test', '1.js')],
					Object.assign({}, defaultApiOptions, {
						clearLogOnNextRun: true,
						runVector: 2
					})
				]);
			});
		});

		test('avoids duplication when both a test and a source dependency change', t => {
			t.plan(2);
			seed();

			change(path.join('test', '1.js'));
			change('dep-1.js');
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '1.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('stops tracking unlinked tests', t => {
			t.plan(2);
			seed();

			unlink(path.join('test', '1.js'));
			change('dep-3.js');
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '2.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('updates test dependencies', t => {
			t.plan(2);
			seed();

			emitDependencies(path.join('test', '1.js'), [path.resolve('dep-4.js')]);
			change('dep-4.js');
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '1.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		[
			{
				desc: 'only tracks source dependencies',
				sources: ['dep-1.js']
			},
			{
				desc: 'exclusion patterns affect tracked source dependencies',
				sources: ['!dep-2.js']
			}
		].forEach(variant => {
			test(variant.desc, 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(() => {
					t.ok(api.run.calledTwice);
					// Expect all tests to be rerun since `dep-2.js` is not a tracked
					// dependency
					t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
						clearLogOnNextRun: true,
						runVector: 2
					})]);
				});
			});
		});

		test('uses default source patterns', t => {
			t.plan(4);
			seed();

			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(path.join('lib', 'util.js'));

			api.run.returns(Promise.resolve(runStatus));
			return debounce(3).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '1.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);

				change('foo.bar');
				return debounce();
			}).then(() => {
				t.ok(api.run.calledThrice);
				// Expect all tests to be rerun since `foo.bar` is not a tracked
				// dependency
				t.strictDeepEqual(api.run.thirdCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 3
				})]);
			});
		});

		test('uses default exclusion patterns', t => {
			t.plan(2);

			// Ensure each directory is treated as containing sources
			seed(['**/*']);

			// Synthesize an excluded file for each directory that's ignored by
			// default. Apply deeper nesting for each file.
			const excludedFiles = defaultIgnore.map((dir, index) => {
				let relPath = dir;
				for (let i = index; i >= 0; i--) {
					relPath = path.join(relPath, String(i));
				}
				return `${relPath}.js`;
			});

			// Ensure `test/1.js` also depends on the excluded files
			emitDependencies(
				path.join('test', '1.js'),
				excludedFiles.map(relPath => path.resolve(relPath)).concat('dep-1.js')
			);

			// Modify all excluded files
			excludedFiles.forEach(x => change(x));

			return debounce(excludedFiles.length).then(() => {
				t.ok(api.run.calledTwice);
				// Since the excluded files are not tracked as a dependency, all tests
				// are expected to be rerun
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('allows default exclusion patterns to be overriden', t => {
			t.plan(2);
			seed(['node_modules/foo/*.js']);

			const dep = path.join('node_modules', 'foo', 'index.js');
			emitDependencies(path.join('test', '1.js'), [path.resolve(dep)]);
			change(dep);

			return debounce(1).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[path.join('test', '1.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('ignores dependencies outside of the current working directory', t => {
			t.plan(4);
			seed(['**/*.js', '..foo.js']);

			emitDependencies(path.join('test', '1.js'), [path.resolve('../outside.js')]);
			emitDependencies(path.join('test', '2.js'), [path.resolve('..foo.js')]);
			// Pretend Chokidar detected a change to verify (normally Chokidar would
			// also be ignoring this file but hey)
			change(path.join('..', 'outside.js'));

			api.run.returns(Promise.resolve(runStatus));
			return debounce().then(() => {
				t.ok(api.run.calledTwice);
				// If `../outside.js` was tracked as a dependency of test/1.js this would
				// have caused `test/1.js` to be rerun. Instead expect all tests to be
				// rerun. This is somewhat artifical: normally changes to `../outside.js`
				// wouldn't even be picked up. However this lets us test dependency
				// tracking without directly inspecting the internal state of the
				// watcher.
				t.strictDeepEqual(api.run.secondCall.args, [files, Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);

				change('..foo.js');
				return debounce();
			}).then(() => {
				t.ok(api.run.calledThrice);
				t.strictDeepEqual(api.run.thirdCall.args, [[path.join('test', '2.js')], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 3
				})]);
			});
		});

		test('logs a debug message when a dependent test is found', t => {
			t.plan(2);
			seed();

			change('dep-1.js');
			return debounce().then(() => {
				t.ok(debug.calledTwice);
				t.strictDeepEqual(debug.secondCall.args, ['ava:watcher', '%s is a dependency of %s', 'dep-1.js', path.join('test', '1.js')]);
			});
		});

		test('logs a debug message when sources remain without dependent tests', t => {
			t.plan(3);
			seed();

			change('cannot-be-mapped.js');
			return debounce().then(() => {
				t.ok(debug.calledThrice);
				t.strictDeepEqual(debug.secondCall.args, ['ava:watcher', 'Sources remain that cannot be traced to specific tests: %O', ['cannot-be-mapped.js']]);
				t.strictDeepEqual(debug.thirdCall.args, ['ava:watcher', 'Rerunning all tests']);
			});
		});
	});

	group('.only is sticky', (beforeEach, test) => {
		let apiEmitter;
		let runStatus;
		let runStatusEmitter;
		beforeEach(() => {
			apiEmitter = new EventEmitter();
			api.on = (event, fn) => {
				apiEmitter.on(event, fn);
			};
			runStatusEmitter = new EventEmitter();
			runStatus = {
				stats: {
					byFile: new Map(),
					declaredTests: 0,
					failedHooks: 0,
					failedTests: 0,
					failedWorkers: 0,
					files,
					finishedWorkers: 0,
					internalErrors: 0,
					remainingTests: 0,
					passedKnownFailingTests: 0,
					passedTests: 0,
					selectedTests: 0,
					skippedTests: 0,
					timeouts: 0,
					todoTests: 0,
					uncaughtExceptions: 0,
					unhandledRejections: 0
				},
				on(event, fn) {
					runStatusEmitter.on(event, fn);
				}
			};
		});

		const emitStats = (testFile, hasExclusive) => {
			runStatus.stats.byFile.set(testFile, {
				declaredTests: 2,
				failedHooks: 0,
				failedTests: 0,
				internalErrors: 0,
				remainingTests: 0,
				passedKnownFailingTests: 0,
				passedTests: 0,
				selectedTests: hasExclusive ? 1 : 2,
				skippedTests: 0,
				todoTests: 0,
				uncaughtExceptions: 0,
				unhandledRejections: 0
			});
			runStatusEmitter.emit('stateChange', {type: 'worker-finished', testFile});
		};

		const t1 = path.join('test', '1.js');
		const t2 = path.join('test', '2.js');
		const t3 = path.join('test', '3.js');
		const t4 = path.join('test', '4.js');

		const seed = () => {
			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			const watcher = start();
			apiEmitter.emit('run', {
				files: [t1, t2, t3, t4],
				status: runStatus
			});
			emitStats(t1, true);
			emitStats(t2, true);
			emitStats(t3, false);
			emitStats(t4, false);

			done();
			api.run.returns(new Promise(() => {}));
			return watcher;
		};

		test('changed test files (none of which previously contained .only) are run in exclusive mode', t => {
			const options = Object.assign({}, defaultApiOptions, {runOnlyExclusive: true});
			t.plan(2);
			seed();

			change(t3);
			change(t4);
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[t1, t2, t3, t4], Object.assign({}, options, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('changed test files (comprising some, but not all, files that previously contained .only) are run in exclusive mode', t => {
			const options = Object.assign({}, defaultApiOptions, {runOnlyExclusive: true});
			t.plan(2);
			seed();

			change(t1);
			change(t4);
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[t1, t2, t4], Object.assign({}, options, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('changed test files (comprising all files that previously contained .only) are run in regular mode', t => {
			t.plan(2);
			seed();

			change(t1);
			change(t2);
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[t1, t2], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('once no test files contain .only, further changed test files are run in regular mode', t => {
			t.plan(2);
			seed();

			emitStats(t1, false);
			emitStats(t2, false);

			change(t3);
			change(t4);
			return debounce(2).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[t3, t4], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('once test files containing .only are removed, further changed test files are run in regular mode', t => {
			t.plan(2);
			seed();

			unlink(t1);
			unlink(t2);
			change(t3);
			change(t4);
			return debounce(4).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[t3, t4], Object.assign({}, defaultApiOptions, {
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});
	});

	group('tracks previous failures', (beforeEach, test) => {
		let apiEmitter;
		let runStatus;
		let runStatusEmitter;
		beforeEach(() => {
			apiEmitter = new EventEmitter();
			api.on = (event, fn) => {
				apiEmitter.on(event, fn);
			};
			runStatusEmitter = new EventEmitter();
			runStatus = {
				stats: {
					byFile: new Map(),
					declaredTests: 0,
					failedHooks: 0,
					failedTests: 0,
					failedWorkers: 0,
					files,
					finishedWorkers: 0,
					internalErrors: 0,
					remainingTests: 0,
					passedKnownFailingTests: 0,
					passedTests: 0,
					selectedTests: 0,
					skippedTests: 0,
					timeouts: 0,
					todoTests: 0,
					uncaughtExceptions: 0,
					unhandledRejections: 0
				},
				on(event, fn) {
					runStatusEmitter.on(event, fn);
				}
			};
		});

		const seed = seedFailures => {
			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			const watcher = start();
			const files = [path.join('test', '1.js'), path.join('test', '2.js')];
			apiEmitter.emit('run', {
				files,
				status: runStatus
			});

			if (seedFailures) {
				seedFailures(files);
			}

			done();
			api.run.returns(new Promise(() => {}));
			return watcher;
		};

		const rerun = function (file) {
			runStatus = {on: runStatus.on};
			let done;
			api.run.returns(new Promise(resolve => {
				done = () => {
					resolve(runStatus);
				};
			}));

			change(file);
			return debounce().then(() => {
				apiEmitter.emit('run', {
					files: [file],
					status: runStatus
				});
				done();

				api.run.returns(new Promise(() => {}));
			});
		};

		test('runs with previousFailures set to number of prevous failures', t => {
			t.plan(2);

			let other;
			seed(files => {
				runStatusEmitter.emit('stateChange', {
					type: 'test-failed',
					testFile: files[0]
				});

				runStatusEmitter.emit('stateChange', {
					type: 'uncaught-exception',
					testFile: files[0]
				});

				other = files[1];
			});

			return rerun(other).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[other], Object.assign({}, defaultApiOptions, {
					previousFailures: 2,
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('tracks failures from multiple files', t => {
			t.plan(2);

			let first;

			seed(files => {
				runStatusEmitter.emit('stateChange', {
					type: 'test-failed',
					testFile: files[0]
				});

				runStatusEmitter.emit('stateChange', {
					type: 'test-failed',
					testFile: files[1]
				});

				first = files[0];
			});

			return rerun(first).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[first], Object.assign({}, defaultApiOptions, {
					previousFailures: 1,
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('previous failures don\'t count when that file is rerun', t => {
			t.plan(2);

			let same;

			seed(files => {
				runStatusEmitter.emit('stateChange', {
					type: 'test-failed',
					testFile: files[0]
				});

				runStatusEmitter.emit('stateChange', {
					type: 'uncaught-exception',
					testFile: files[0]
				});

				same = files[0];
			});

			return rerun(same).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[same], Object.assign({}, defaultApiOptions, {
					previousFailures: 0,
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});

		test('previous failures don\'t count when that file is deleted', t => {
			t.plan(2);

			let same;
			let other;

			seed(files => {
				runStatusEmitter.emit('stateChange', {
					type: 'test-failed',
					testFile: files[0]
				});

				runStatusEmitter.emit('stateChange', {
					type: 'uncaught-exception',
					testFile: files[0]
				});

				same = files[0];
				other = files[1];
			});

			unlink(same);

			return debounce().then(() => rerun(other)).then(() => {
				t.ok(api.run.calledTwice);
				t.strictDeepEqual(api.run.secondCall.args, [[other], Object.assign({}, defaultApiOptions, {
					previousFailures: 0,
					clearLogOnNextRun: true,
					runVector: 2
				})]);
			});
		});
	});
});
