diff --git a/lib/mighty_test/console.rb b/lib/mighty_test/console.rb index 7f98df9..fdb012f 100644 --- a/lib/mighty_test/console.rb +++ b/lib/mighty_test/console.rb @@ -1,4 +1,5 @@ require "io/console" +require "io/wait" module MightyTest class Console @@ -15,11 +16,14 @@ def clear true end - def wait_for_keypress - with_raw_input do - sleep if stdin.eof? - stdin.getc - end + def with_raw_input(&) + return yield unless stdin.respond_to?(:raw) && tty? + + stdin.raw(intr: true, &) + end + + def read_keypress_nonblock + stdin.getc if stdin.wait_readable(0) end def play_sound(name, wait: false) @@ -55,11 +59,5 @@ def play_sound(name, wait: false) def tty? $stdout.respond_to?(:tty?) && $stdout.tty? end - - def with_raw_input(&) - return yield unless stdin.respond_to?(:raw) && tty? - - stdin.raw(intr: true, &) - end end end diff --git a/lib/mighty_test/watcher.rb b/lib/mighty_test/watcher.rb index 5432c5f..052d82a 100644 --- a/lib/mighty_test/watcher.rb +++ b/lib/mighty_test/watcher.rb @@ -1,58 +1,41 @@ +require_relative "watcher/event_queue" + module MightyTest class Watcher - class ListenerTriggered < StandardError - attr_reader :paths - - def initialize(paths) - @paths = paths - super() - end - end - WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "h" for help or "q" to quit.'.freeze - def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system)) + def initialize(console: Console.new, extra_args: [], event_queue: nil, file_system: nil, system_proc: nil) @console = console @extra_args = extra_args - @file_system = file_system - @system_proc = system_proc + @file_system = file_system || FileSystem.new + @system_proc = system_proc || method(:system) + @event_queue = event_queue || EventQueue.new(console: @console, file_system: @file_system) end - def run(iterations: :indefinitely) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength - started = false - @foreground_thread = Thread.current - - loop_for(iterations) do - start_file_system_listener && puts(WATCHING_FOR_CHANGES) unless started - started = true - - case console.wait_for_keypress - when "\r", "\n" - run_all_tests - when "a" - run_all_tests(flags: ["--all"]) - when "d" - run_matching_test_files_from_git_diff - when "h" - show_help - when "q" - file_system_listener.stop - break + def run + event_queue.start + puts WATCHING_FOR_CHANGES + + loop do + case event_queue.pop + in [:file_system_changed, [_, *] => paths] then run_matching_test_files(paths) + in [:keypress, "\r" | "\n"] then run_all_tests + in [:keypress, "a"] then run_all_tests(flags: ["--all"]) + in [:keypress, "d"] then run_matching_test_files_from_git_diff + in [:keypress, "h"] then show_help + in [:keypress, "q"] then break + else + nil end - rescue ListenerTriggered => e - run_matching_test_files(e.paths) - file_system_listener.start if file_system_listener.paused? - rescue Interrupt - file_system_listener&.stop - raise end ensure + event_queue.stop puts "\nExiting." end private - attr_reader :console, :extra_args, :file_system, :file_system_listener, :system_proc, :foreground_thread + attr_reader :console, :extra_args, :file_system, :event_queue, :system_proc def show_help console.clear @@ -109,26 +92,7 @@ def mt(*test_paths, flags: []) $stdout.flush rescue Interrupt # Pressing ctrl-c kills the fs_event background process, so we have to manually restart it. - # Do this in a separate thread to work around odd behavior on Ruby 3.4. - Thread.new { restart_file_system_listener } - end - - def start_file_system_listener - file_system_listener.stop if file_system_listener && !file_system_listener.stopped? - - @file_system_listener = file_system.listen do |modified, added, _removed| - paths = [*modified, *added].uniq - next if paths.empty? - - # Pause listener so that subsequent changes are queued up while we are running the tests - file_system_listener.pause unless file_system_listener.stopped? - foreground_thread.raise ListenerTriggered.new(paths) - end - end - alias restart_file_system_listener start_file_system_listener - - def loop_for(iterations, &) - iterations == :indefinitely ? loop(&) : iterations.times(&) + event_queue.restart end end end diff --git a/lib/mighty_test/watcher/event_queue.rb b/lib/mighty_test/watcher/event_queue.rb new file mode 100644 index 0000000..9acde3a --- /dev/null +++ b/lib/mighty_test/watcher/event_queue.rb @@ -0,0 +1,78 @@ +require "io/console" + +module MightyTest + class Watcher + class EventQueue + def initialize(console: Console.new, file_system: FileSystem.new) + @console = console + @file_system = file_system + @file_system_queue = Thread::Queue.new + end + + def pop + console.with_raw_input do + until stopped? + if (key = console.read_keypress_nonblock) + return [:keypress, key] + end + if (paths = pop_files_changed) + return [:file_system_changed, paths] + end + end + end + end + + def start + raise "Already started" unless stopped? + + @file_system_listener = file_system.listen do |modified, added, _removed| + paths = [*modified, *added].uniq + file_system_queue.push(paths) unless paths.empty? + end + true + end + + def restart + stop + start + end + + def stop + file_system_listener&.stop + @file_system_listener = nil + end + + def stopped? + !file_system_listener + end + + private + + attr_reader :console, :file_system, :file_system_listener, :file_system_queue + + def pop_files_changed + paths = try_file_system_pop(timeout: 0.2) + return if paths.nil? + + paths += file_system_queue.pop until file_system_queue.empty? + paths.uniq + end + + if RUBY_VERSION.start_with?("3.1.") + # TODO: Remove once we drop support for Ruby 3.1 + require "timeout" + def try_file_system_pop(timeout:) + Timeout.timeout(timeout) do + file_system_queue.pop + end + rescue Timeout::Error + nil + end + else + def try_file_system_pop(timeout:) + file_system_queue.pop(timeout:) + end + end + end + end +end diff --git a/test/mighty_test/console_test.rb b/test/mighty_test/console_test.rb index cac65f4..df810b1 100644 --- a/test/mighty_test/console_test.rb +++ b/test/mighty_test/console_test.rb @@ -20,10 +20,20 @@ def test_clear_clears_the_screen_and_returns_true_and_if_tty assert_equal "clear!", stdout end - def test_wait_for_keypress_returns_the_next_character_on_stdin - console = Console.new(stdin: StringIO.new("hi")) + def test_read_keypress_nonblock_returns_the_next_character_on_stdin + stdin = StringIO.new("hi") + stdin.define_singleton_method(:wait_readable) { |_timeout| true } + console = Console.new(stdin:) - assert_equal "h", console.wait_for_keypress + assert_equal "h", console.read_keypress_nonblock + end + + def test_read_keypress_nonblock_returns_nil_if_nothing_is_in_buffer + stdin = StringIO.new + stdin.define_singleton_method(:wait_readable) { |_timeout| false } + console = Console.new(stdin:) + + assert_nil console.read_keypress_nonblock end def test_play_sound_returns_false_if_not_tty diff --git a/test/mighty_test/watcher_test.rb b/test/mighty_test/watcher_test.rb index cb44763..4fb5d75 100644 --- a/test/mighty_test/watcher_test.rb +++ b/test/mighty_test/watcher_test.rb @@ -4,46 +4,47 @@ module MightyTest class WatcherTest < Minitest::Test include FixturesPath + def setup + @event_queue = FakeEventQueue.new + @system_proc = nil + end + 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 + event_queue.push :file_system_changed, %w[lib/example.rb test/focused_test.rb test/focused_test.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + stdout, = run_watcher(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 + event_queue.push :file_system_changed, %w[lib/example/version.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + stdout, = run_watcher(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 + event_queue.push :file_system_changed, %w[test/example_test.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, extra_args: %w[-w --fail-fast], in: fixtures_path.join("example_project")) + stdout, = run_watcher(extra_args: %w[-w --fail-fast], in: fixtures_path.join("example_project")) assert_includes(stdout, "[SYSTEM] mt -w --fail-fast -- test/example_test.rb\n") end def test_watcher_clears_the_screen_and_prints_the_test_file_being_run_prior_to_executing_the_mt_command system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } - listen_thread do |callback| - callback.call(["test/example_test.rb"], [], []) - end + event_queue.push :file_system_changed, %w[test/example_test.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) [CLEAR] @@ -58,11 +59,10 @@ def test_watcher_prints_a_status_message_and_plays_a_sound_after_successful_test puts "[SYSTEM] #{args.join(' ')}" true end - listen_thread do |callback| - callback.call(["test/example_test.rb"], [], []) - end + event_queue.push :file_system_changed, %w[test/example_test.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) [SYSTEM] mt -- test/example_test.rb @@ -77,11 +77,10 @@ def test_watcher_prints_a_status_message_and_plays_a_sound_after_failed_test_run puts "[SYSTEM] #{args.join(' ')}" false end - listen_thread do |callback| - callback.call(["test/example_test.rb"], [], []) - end + event_queue.push :file_system_changed, %w[test/example_test.rb] + event_queue.push :keypress, "q" - stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project")) + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) [SYSTEM] mt -- test/example_test.rb @@ -92,19 +91,20 @@ def test_watcher_prints_a_status_message_and_plays_a_sound_after_failed_test_run end def test_watcher_restarts_the_listener_when_a_test_run_is_interrupted - thread_count = 0 + restarted = false 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) + event_queue.push :file_system_changed, %w[test/example_test.rb] + event_queue.push :keypress, "q" + event_queue.define_singleton_method(:restart) { restarted = true } + + run_watcher(in: fixtures_path.join("example_project")) + assert restarted end def test_watcher_exits_when_q_key_is_pressed - stdout, = run_watcher(stdin: "q", in: fixtures_path.join("example_project")) + event_queue.push :keypress, "q" + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, "Exiting.") end @@ -114,8 +114,10 @@ def test_watcher_runs_all_tests_when_enter_key_is_pressed puts "[SYSTEM] #{args.join(' ')}" true end + event_queue.push :keypress, "\r" + event_queue.push :keypress, "q" - stdout, = run_watcher(stdin: "\rq", in: fixtures_path.join("example_project")) + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) Running tests... @@ -129,8 +131,10 @@ def test_watcher_runs_all_tests_with_all_flag_when_a_key_is_pressed puts "[SYSTEM] #{args.join(' ')}" true end + event_queue.push :keypress, "a" + event_queue.push :keypress, "q" - stdout, = run_watcher(stdin: "aq", in: fixtures_path.join("example_project")) + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) Running tests with --all... @@ -144,10 +148,12 @@ def test_watcher_runs_new_and_changed_files_according_to_git_when_d_key_is_press puts "[SYSTEM] #{args.join(' ')}" true end + event_queue.push :keypress, "d" + event_queue.push :keypress, "q" 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")) + run_watcher(file_system:, in: fixtures_path.join("example_project")) end assert_includes(stdout, <<~EXPECTED) @@ -163,10 +169,12 @@ def test_watcher_shows_a_message_if_d_key_is_pressed_and_there_are_no_changes puts "[SYSTEM] #{args.join(' ')}" true end + event_queue.push :keypress, "d" + event_queue.push :keypress, "q" 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")) + run_watcher(file_system:, in: fixtures_path.join("example_project")) end assert_includes(stdout, <<~EXPECTED) @@ -177,7 +185,10 @@ def test_watcher_shows_a_message_if_d_key_is_pressed_and_there_are_no_changes end def test_watcher_shows_help_menu_when_h_key_is_pressed - stdout, = run_watcher(stdin: "hq", in: fixtures_path.join("example_project")) + event_queue.push :keypress, "h" + event_queue.push :keypress, "q" + + stdout, = run_watcher(in: fixtures_path.join("example_project")) assert_includes(stdout, <<~EXPECTED) > Press Enter to run all tests. @@ -190,50 +201,38 @@ def test_watcher_shows_help_menu_when_h_key_is_pressed private - class Listener - def initialize(thread, callback) - Thread.new do - thread&.call(callback) - end - end - - def start - end + attr_reader :event_queue - def stop + class FakeEventQueue + def initialize + @events = [] end - def pause + def push(type, payload) + @events.unshift([type, payload]) end - def stopped? - false + def pop + @events.pop end - def paused? - false - end + def start; end + def stop; end end - def run_watcher(iterations: :indefinitely, in: ".", extra_args: [], stdin: nil, file_system: FileSystem.new) - listen_thread = @listen_thread - console = Console.new(stdin: stdin.nil? ? StringIO.new : StringIO.new(stdin)) + def run_watcher(in: ".", file_system: FileSystem.new, extra_args: []) + console = Console.new console.define_singleton_method(:clear) { puts "[CLEAR]" } console.define_singleton_method(:play_sound) { |sound| puts "[SOUND] #{sound.inspect}" } - 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(console:, extra_args:, file_system:, system_proc: @system_proc) - @watcher.run(iterations:) + @watcher = Watcher.new(console:, extra_args:, event_queue:, file_system:, system_proc: @system_proc) + @watcher.run end end end - def listen_thread(&thread) - @listen_thread = thread - end - def system_proc(&proc) @system_proc = proc end