diff --git a/.rubocop.yml b/.rubocop.yml index e09e54a..d5daca2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -48,6 +48,10 @@ Layout/MultilineMethodCallIndentation: Layout/SpaceAroundEqualsInParameterDefault: EnforcedStyle: no_space +Lint/EmptyFile: + Exclude: + - "test/fixtures/**/*" + Metrics/AbcSize: Max: 20 Exclude: diff --git a/lib/mighty_test.rb b/lib/mighty_test.rb index d20b6fe..e1d62d7 100644 --- a/lib/mighty_test.rb +++ b/lib/mighty_test.rb @@ -1,7 +1,9 @@ module MightyTest autoload :VERSION, "mighty_test/version" autoload :CLI, "mighty_test/cli" + autoload :FileSystem, "mighty_test/file_system" autoload :MinitestRunner, "mighty_test/minitest_runner" autoload :OptionParser, "mighty_test/option_parser" autoload :TestParser, "mighty_test/test_parser" + autoload :Watcher, "mighty_test/watcher" end diff --git a/lib/mighty_test/cli.rb b/lib/mighty_test/cli.rb index 551c40a..5f08a32 100644 --- a/lib/mighty_test/cli.rb +++ b/lib/mighty_test/cli.rb @@ -13,6 +13,8 @@ def run(argv: ARGV) print_help elsif options[:version] puts VERSION + elsif options[:watch] + watch elsif path_args.grep(/.:\d+$/).any? run_test_by_line_number else @@ -33,6 +35,10 @@ def print_help runner.print_help_and_exit! end + def watch + Watcher.new(extra_args:).run + end + def run_test_by_line_number path, line = path_args.first.match(/^(.+):(\d+)$/).captures test_name = TestParser.new(path).test_name_at_line(line.to_i) diff --git a/lib/mighty_test/file_system.rb b/lib/mighty_test/file_system.rb new file mode 100644 index 0000000..222cc9f --- /dev/null +++ b/lib/mighty_test/file_system.rb @@ -0,0 +1,16 @@ +module MightyTest + class FileSystem + def listen(&) + require "listen" + Listen.to(*%w[app lib test].select { |p| Dir.exist?(p) }, relative: true, &).tap(&:start) + end + + def find_matching_test_file(path) + return nil unless path && File.exist?(path) && !Dir.exist?(path) + return path if path.match?(%r{^test/.*_test.rb$}) + + test_path = path[%r{^(?:app|lib)/(.+)\.[^\.]+$}, 1].then { "test/#{_1}_test.rb" } + test_path if test_path && File.exist?(test_path) + end + end +end diff --git a/lib/mighty_test/option_parser.rb b/lib/mighty_test/option_parser.rb index f107214..d79b380 100644 --- a/lib/mighty_test/option_parser.rb +++ b/lib/mighty_test/option_parser.rb @@ -1,15 +1,17 @@ +require "optparse" + module MightyTest class OptionParser def initialize - require "optparse" - @parser = ::OptionParser.new do |op| op.require_exact = true op.banner = <<~BANNER Usage: mt ... + mt --watch BANNER + op.on("--watch") { options[:watch] = true } op.on("-h", "--help") { options[:help] = true } op.on("--version") { options[:version] = true } end diff --git a/lib/mighty_test/watcher.rb b/lib/mighty_test/watcher.rb new file mode 100644 index 0000000..16c4827 --- /dev/null +++ b/lib/mighty_test/watcher.rb @@ -0,0 +1,71 @@ +require "concurrent" + +module MightyTest + class Watcher + WATCHING_FOR_CHANGES = "Watching for changes to source and test files. Press ctrl-c to exit.".freeze + + def initialize(extra_args: [], file_system: FileSystem.new, system_proc: method(:system)) + @event = Concurrent::MVar.new + @extra_args = extra_args + @file_system = file_system + @system_proc = system_proc + end + + def run(iterations: :indefinitely) + start_listener + puts WATCHING_FOR_CHANGES + + loop_for(iterations) do + case await_next_event + in [:file_system_changed, paths] + mt(*paths) if paths.any? + in [:tests_completed, :pass | :fail] + puts WATCHING_FOR_CHANGES + end + end + ensure + listener&.stop + end + + private + + attr_reader :extra_args, :file_system, :listener, :system_proc + + def mt(*test_paths) + success = system_proc.call("mt", *extra_args, "--", *test_paths.flatten) + post_event(:tests_completed, success ? :pass : :fail) + rescue Interrupt + # Pressing ctrl-c kills the fs_event background process, so we have to manually restart it. + restart_listener + end + + def start_listener + listener.stop if listener && !listener.stopped? + + @listener = file_system.listen do |modified, added, _removed| + # Pause listener so that subsequent changes are queued up while we are running the tests + listener.pause unless listener.stopped? + + test_paths = [*modified, *added].filter_map do |path| + file_system.find_matching_test_file(path) + end + + post_event(:file_system_changed, test_paths.uniq) + end + end + alias restart_listener start_listener + + def loop_for(iterations, &) + iterations == :indefinitely ? loop(&) : iterations.times(&) + end + + def await_next_event + listener.start if listener.paused? + @event.take + end + + def post_event(*event) + @event.put(event) + end + end +end diff --git a/mighty_test.gemspec b/mighty_test.gemspec index 210f59c..624ef9a 100644 --- a/mighty_test.gemspec +++ b/mighty_test.gemspec @@ -26,6 +26,8 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Runtime dependencies + spec.add_dependency "concurrent-ruby", "~> 1.1" + spec.add_dependency "listen", "~> 3.5" spec.add_dependency "minitest", "~> 5.15" spec.add_dependency "minitest-fail-fast", "~> 0.1.0" spec.add_dependency "minitest-focus", "~> 1.4" diff --git a/test/fixtures/rails_project/app/models/user.rb b/test/fixtures/rails_project/app/models/user.rb new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/rails_project/test/models/user_test.rb b/test/fixtures/rails_project/test/models/user_test.rb new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/mt_test.rb b/test/integration/mt_test.rb index 370a74e..fa02c2f 100644 --- a/test/integration/mt_test.rb +++ b/test/integration/mt_test.rb @@ -57,6 +57,28 @@ def test_mt_runs_no_tests_if_line_number_doesnt_match refute_match(/FailingTest/, result.stdout) end + def test_mt_runs_watch_mode_that_executes_tests_when_files_change + project_dir = fixtures_path.join("example_project") + stdout, = capture_subprocess_io do + # Start mt --watch in the background + pid = spawn(*%w[bundle exec mt --watch --verbose], chdir: project_dir) + + # mt needs time to launch and start its file system listener + sleep 1 + + # Touch a file and wait for mt --watch to detect the change and run the corresponding test + FileUtils.touch project_dir.join("lib/example.rb") + sleep 1 + + # OK, we're done here. Tell mt --watch to exit. + Process.kill(:INT, pid) + end + + assert_includes(stdout, "Watching for changes to source and test files.") + assert_match(/ExampleTest/, stdout) + assert_match(/\d runs, \d assertions, 0 failures, 0 errors/, stdout) + end + private def bundle_exec_mt(argv:, env: { "CI" => nil }, chdir: nil, raise_on_failure: true) diff --git a/test/mighty_test/file_system_test.rb b/test/mighty_test/file_system_test.rb new file mode 100644 index 0000000..6d7b6da --- /dev/null +++ b/test/mighty_test/file_system_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +module MightyTest + class FileSystemTest < Minitest::Test + include FixturesPath + + def test_find_matching_test_file_returns_nil_for_nil_path + assert_nil find_matching_test_file(nil) + end + + def test_find_matching_test_file_returns_nil_for_non_existent_path + assert_nil find_matching_test_file("path/to/nowhere.rb") + end + + def test_find_matching_test_file_returns_nil_for_directory_path + assert_nil find_matching_test_file("lib/example", in: fixtures_path.join("example_project")) + end + + def test_find_matching_test_file_returns_nil_for_path_with_no_corresponding_test + assert_nil find_matching_test_file("lib/example/version.rb", in: fixtures_path.join("example_project")) + end + + def test_find_matching_test_file_returns_nil_for_a_test_support_file + assert_nil find_matching_test_file("test/test_helper.rb", in: fixtures_path.join("example_project")) + end + + def test_find_matching_test_file_returns_argument_if_it_is_already_a_test + test_path = find_matching_test_file("test/example_test.rb", in: fixtures_path.join("example_project")) + assert_equal("test/example_test.rb", test_path) + end + + def test_find_matching_test_file_returns_matching_test_given_an_implementation_path_in_a_gem_project + test_path = find_matching_test_file("lib/example.rb", in: fixtures_path.join("example_project")) + assert_equal("test/example_test.rb", test_path) + end + + def test_find_matching_test_file_returns_matching_test_given_a_model_path_in_a_rails_project + test_path = find_matching_test_file("app/models/user.rb", in: fixtures_path.join("rails_project")) + assert_equal("test/models/user_test.rb", test_path) + end + + private + + def find_matching_test_file(path, in: ".") + Dir.chdir(binding.local_variable_get(:in)) do + FileSystem.new.find_matching_test_file(path) + end + end + end +end diff --git a/test/mighty_test/watcher_test.rb b/test/mighty_test/watcher_test.rb new file mode 100644 index 0000000..bf4c41c --- /dev/null +++ b/test/mighty_test/watcher_test.rb @@ -0,0 +1,133 @@ +require "test_helper" + +module MightyTest + class WatcherTest < Minitest::Test + include FixturesPath + + def test_watcher_passes_unique_set_of_test_files_to_mt_command_based_on_changes_detected + system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } + listen_thread do |callback| + callback.call(["lib/example.rb", "test/focused_test.rb"], ["test/focused_test.rb"], []) + end + + stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + + assert_includes(stdout, "[SYSTEM] mt -- test/example_test.rb test/focused_test.rb\n") + end + + def test_watcher_does_nothing_if_a_detected_change_has_no_corresponding_test_file + system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } + listen_thread do |callback| + callback.call(["lib/example/version.rb"], [], []) + end + + stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + + refute_includes(stdout, "[SYSTEM]") + end + + def test_watcher_passes_extra_args_through_to_mt_command + system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } + listen_thread do |callback| + callback.call(["test/example_test.rb"], [], []) + end + + stdout, = run_watcher(iterations: 1, extra_args: ["--fail-fast"], in: fixtures_path.join("example_project")) + + assert_includes(stdout, "[SYSTEM] mt --fail-fast -- test/example_test.rb\n") + end + + def test_watcher_prints_a_status_message_after_successful_test_run + system_proc do |*args| + puts "[SYSTEM] #{args.join(' ')}" + true + end + listen_thread do |callback| + callback.call(["test/example_test.rb"], [], []) + end + + stdout, = run_watcher(iterations: 2, in: fixtures_path.join("example_project")) + + assert_includes(stdout, <<~EXPECTED) + [SYSTEM] mt -- test/example_test.rb + Watching for changes to source and test files. Press ctrl-c to exit. + EXPECTED + end + + def test_watcher_prints_a_status_message_after_failed_test_run + system_proc do |*args| + puts "[SYSTEM] #{args.join(' ')}" + false + end + listen_thread do |callback| + callback.call(["test/example_test.rb"], [], []) + end + + stdout, = run_watcher(iterations: 2, in: fixtures_path.join("example_project")) + + assert_includes(stdout, <<~EXPECTED) + [SYSTEM] mt -- test/example_test.rb + Watching for changes to source and test files. Press ctrl-c to exit. + EXPECTED + end + + def test_watcher_restarts_the_listener_when_a_test_run_is_interrupted + thread_count = 0 + system_proc { |*| raise Interrupt } + listen_thread do |callback| + thread_count += 1 + callback.call(["test/example_test.rb"], [], []) unless thread_count > 2 + end + + run_watcher(iterations: 2, in: fixtures_path.join("example_project")) + assert_equal(2, thread_count) + end + + private + + class Listener + def initialize(thread, callback) + Thread.new do + thread.call(callback) + end + end + + def start + end + + def stop + end + + def pause + end + + def stopped? + false + end + + def paused? + false + end + end + + def run_watcher(iterations:, in: ".", extra_args: []) + listen_thread = @listen_thread + file_system = FileSystem.new + file_system.define_singleton_method(:listen) { |&callback| Listener.new(listen_thread, callback) } + capture_io do + Dir.chdir(binding.local_variable_get(:in)) do + @watcher = Watcher.new(extra_args:, file_system:, system_proc: @system_proc) + @watcher.run(iterations:) + end + end + end + + def listen_thread(&thread) + @listen_thread = thread + end + + def system_proc(&proc) + @system_proc = proc + end + end +end