From 8ce978b4e1553b0d3b851ced1251409d59a80d55 Mon Sep 17 00:00:00 2001 From: Matt Brictson Date: Wed, 21 Feb 2024 17:20:51 -0800 Subject: [PATCH] Add interactive watch command for running tests based on git status The `git ls-files` command allows us to get a list of all files that have been added or changed since the last commit. This is a convenient way to determine what tests to run. This commit adds a "d" keypress command to the watch mode. When the "d" key is pressed, we check git for new and changed files, then find the corresponding test files, and run those. --- lib/mighty_test/file_system.rb | 11 ++++++++ lib/mighty_test/watcher.rb | 17 +++++++++++- test/mighty_test/file_system_test.rb | 41 ++++++++++++++++++++++++++++ test/mighty_test/watcher_test.rb | 40 +++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/lib/mighty_test/file_system.rb b/lib/mighty_test/file_system.rb index 38fda07..e7e8875 100644 --- a/lib/mighty_test/file_system.rb +++ b/lib/mighty_test/file_system.rb @@ -1,3 +1,5 @@ +require "open3" + module MightyTest class FileSystem def listen(&) @@ -17,5 +19,14 @@ def find_test_paths(directory="test") glob = File.join(directory, "**/*_test.rb") Dir[glob] end + + def find_new_and_changed_paths + out, _err, status = Open3.capture3(*%w[git ls-files --deduplicate -m -o --exclude-standard test app lib]) + return [] unless status.success? + + out.lines(chomp: true).reject(&:empty?).uniq + rescue SystemCallError + [] + end end end diff --git a/lib/mighty_test/watcher.rb b/lib/mighty_test/watcher.rb index adb1dbf..eea9819 100644 --- a/lib/mighty_test/watcher.rb +++ b/lib/mighty_test/watcher.rb @@ -12,7 +12,7 @@ def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new @system_proc = system_proc end - def run(iterations: :indefinitely) # rubocop:disable Metrics/MethodLength + def run(iterations: :indefinitely) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize start_file_system_listener start_keypress_listener puts WATCHING_FOR_CHANGES @@ -28,6 +28,16 @@ def run(iterations: :indefinitely) # rubocop:disable Metrics/MethodLength console.clear puts "Running all tests...\n\n" mt + in [:keypress, "d"] + console.clear + if (paths = find_matching_tests_for_new_and_changed_paths).any? + puts paths.join("\n") + puts + mt(*paths) + else + puts "No affected test files detected since the last git commit." + puts WATCHING_FOR_CHANGES + end in [:keypress, "q"] break else @@ -43,6 +53,11 @@ def run(iterations: :indefinitely) # rubocop:disable Metrics/MethodLength attr_reader :console, :extra_args, :file_system, :listener, :system_proc + def find_matching_tests_for_new_and_changed_paths + new_changed = file_system.find_new_and_changed_paths + new_changed.flat_map { |path| file_system.find_matching_test_path(path) }.uniq + end + def mt(*test_paths) command = ["mt", *extra_args] command.append("--", *test_paths.flatten) if test_paths.any? diff --git a/test/mighty_test/file_system_test.rb b/test/mighty_test/file_system_test.rb index a2c03bd..ae12a50 100644 --- a/test/mighty_test/file_system_test.rb +++ b/test/mighty_test/file_system_test.rb @@ -71,6 +71,47 @@ def test_find_test_paths_returns_test_files_in_specific_directory ) end + def test_find_new_and_changed_paths_returns_empty_array_if_git_exits_with_error + status = Minitest::Mock.new + status.expect(:success?, false) + + paths = Open3.stub(:capture3, ["", "oh no!", status]) do + FileSystem.new.find_new_and_changed_paths + end + + assert_empty paths + end + + def test_find_new_and_changed_paths_returns_empty_array_if_system_call_fails + paths = Open3.stub(:capture3, ->(*) { raise SystemCallError, "oh no!" }) do + FileSystem.new.find_new_and_changed_paths + end + + assert_empty paths + end + + def test_find_new_and_changed_paths_returns_array_based_on_git_output + git_output = <<~OUT + lib/mighty_test/file_system.rb + test/mighty_test/file_system_test.rb + OUT + + status = Minitest::Mock.new + status.expect(:success?, true) + + paths = Open3.stub(:capture3, [git_output, "", status]) do + FileSystem.new.find_new_and_changed_paths + end + + assert_equal( + %w[ + lib/mighty_test/file_system.rb + test/mighty_test/file_system_test.rb + ], + paths + ) + end + private def find_matching_test_path(path, in: ".") diff --git a/test/mighty_test/watcher_test.rb b/test/mighty_test/watcher_test.rb index a6ff71a..98abd6f 100644 --- a/test/mighty_test/watcher_test.rb +++ b/test/mighty_test/watcher_test.rb @@ -124,6 +124,43 @@ def test_watcher_runs_all_tests_when_enter_key_is_pressed EXPECTED end + def test_watcher_runs_new_and_changed_files_according_to_git_when_d_key_is_pressed + system_proc do |*args| + puts "[SYSTEM] #{args.join(' ')}" + true + end + + file_system = FileSystem.new + stdout, = file_system.stub(:find_new_and_changed_paths, %w[lib/example.rb]) do + run_watcher(file_system:, stdin: "dq", in: fixtures_path.join("example_project")) + end + + assert_includes(stdout, <<~EXPECTED) + [CLEAR] + test/example_test.rb + + [SYSTEM] mt -- test/example_test.rb + EXPECTED + end + + def test_watcher_shows_a_message_if_d_key_is_pressed_and_there_are_no_changes + system_proc do |*args| + puts "[SYSTEM] #{args.join(' ')}" + true + end + + file_system = FileSystem.new + stdout, = file_system.stub(:find_new_and_changed_paths, []) do + run_watcher(file_system:, stdin: "dq", in: fixtures_path.join("example_project")) + end + + assert_includes(stdout, <<~EXPECTED) + [CLEAR] + No affected test files detected since the last git commit. + Watching for changes to source and test files. Press "q" to quit. + EXPECTED + end + private class Listener @@ -151,12 +188,11 @@ def paused? end end - def run_watcher(iterations: :indefinitely, in: ".", extra_args: [], stdin: nil) + def run_watcher(iterations: :indefinitely, in: ".", extra_args: [], stdin: nil, file_system: FileSystem.new) listen_thread = @listen_thread console = Console.new(stdin: stdin.nil? ? File::NULL : StringIO.new(stdin)) console.define_singleton_method(:clear) { puts "[CLEAR]" } console.define_singleton_method(:play_sound) { |sound| puts "[SOUND] #{sound.inspect}" } - file_system = FileSystem.new file_system.define_singleton_method(:listen) { |&callback| Listener.new(listen_thread, callback) } capture_io do