Skip to content

Commit

Permalink
Add keyboard commands to watch mode
Browse files Browse the repository at this point in the history
This commit makes watch mode interactive. It now understands these keys:

- Press ENTER to run all tests
- Press "q" to quit

To do this, I start another background thread that listens for key
presses. When a key is pressed, the thread posts a `:keypress` event to
the event loop. The event loop then takes an appropriate action.

Because they keypress listener is running in a thread separate from the
file system listener, that means the watcher is still able to detect
changes to files and auto-run tests. The event loop funnels all of these
events into a single thread of execution.
  • Loading branch information
mattbrictson committed Feb 22, 2024
1 parent dedbded commit 7c69c77
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 24 deletions.
11 changes: 9 additions & 2 deletions lib/mighty_test/console.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

module MightyTest
class Console
def initialize(sound_player: "/usr/bin/afplay", sound_paths: SOUNDS)
def initialize(stdin: $stdin, sound_player: "/usr/bin/afplay", sound_paths: SOUNDS)
@stdin = stdin
@sound_player = sound_player
@sound_paths = sound_paths
end
Expand All @@ -14,6 +15,12 @@ def clear
true
end

def wait_for_keypress
return stdin.getc unless stdin.respond_to?(:raw)

stdin.raw(intr: true) { stdin.getc }
end

def play_sound(name, wait: false)
return false unless tty?

Expand Down Expand Up @@ -42,7 +49,7 @@ def play_sound(name, wait: false)
private_constant :SOUNDS
# rubocop:enable Layout/LineLength

attr_reader :sound_player, :sound_paths
attr_reader :sound_player, :sound_paths, :stdin

def tty?
$stdout.respond_to?(:tty?) && $stdout.tty?
Expand Down
54 changes: 39 additions & 15 deletions lib/mighty_test/watcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module MightyTest
class Watcher
WATCHING_FOR_CHANGES = "Watching for changes to source and test files. Press ctrl-c to exit.".freeze
WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "q" to quit.'.freeze

def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system))
@event = Concurrent::MVar.new
Expand All @@ -12,17 +12,25 @@ def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new
@system_proc = system_proc
end

def run(iterations: :indefinitely)
start_listener
def run(iterations: :indefinitely) # rubocop:disable Metrics/MethodLength
start_file_system_listener
start_keypress_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, status]
console.play_sound(status)
puts "\n#{WATCHING_FOR_CHANGES}"
in [:file_system_changed, [_, *] => paths]
console.clear
puts [*paths.join("\n"), ""]
mt(*paths)
in [:keypress, "\r" | "\n"]
console.clear
puts "Running all tests...\n\n"
mt
in [:keypress, "q"]
break
else
nil
end
end
ensure
Expand All @@ -35,16 +43,19 @@ def run(iterations: :indefinitely)
attr_reader :console, :extra_args, :file_system, :listener, :system_proc

def mt(*test_paths)
console.clear
puts [*test_paths.join("\n"), ""] if test_paths.any?
success = system_proc.call("mt", *extra_args, "--", *test_paths.flatten)
post_event(:tests_completed, success ? :pass : :fail)
command = ["mt", *extra_args]
command.append("--", *test_paths.flatten) if test_paths.any?

success = system_proc.call(*command)

console.play_sound(success ? :pass : :fail)
puts "\n#{WATCHING_FOR_CHANGES}"
rescue Interrupt
# Pressing ctrl-c kills the fs_event background process, so we have to manually restart it.
restart_listener
restart_file_system_listener
end

def start_listener
def start_file_system_listener
listener.stop if listener && !listener.stopped?

@listener = file_system.listen do |modified, added, _removed|
Expand All @@ -58,7 +69,20 @@ def start_listener
post_event(:file_system_changed, test_paths.uniq)
end
end
alias restart_listener start_listener
alias restart_file_system_listener start_file_system_listener

def start_keypress_listener
Thread.new do
loop do
key = console.wait_for_keypress
post_event(:keypress, key)
rescue Interrupt
retry
end
rescue StandardError
# ignore
end
end

def loop_for(iterations, &)
iterations == :indefinitely ? loop(&) : iterations.times(&)
Expand Down
6 changes: 6 additions & 0 deletions test/mighty_test/console_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ 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"))

assert_equal "h", console.wait_for_keypress
end

def test_play_sound_returns_false_if_not_tty
result = nil
capture_io { result = Console.new.play_sound(:pass) }
Expand Down
36 changes: 29 additions & 7 deletions test/mighty_test/watcher_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ def test_watcher_prints_a_status_message_and_plays_a_sound_after_successful_test
callback.call(["test/example_test.rb"], [], [])
end

stdout, = run_watcher(iterations: 2, in: fixtures_path.join("example_project"))
stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project"))

assert_includes(stdout, <<~EXPECTED)
[SYSTEM] mt -- test/example_test.rb
[SOUND] :pass
Watching for changes to source and test files. Press ctrl-c to exit.
Watching for changes to source and test files. Press "q" to quit.
EXPECTED
end

Expand All @@ -81,13 +81,13 @@ def test_watcher_prints_a_status_message_and_plays_a_sound_after_failed_test_run
callback.call(["test/example_test.rb"], [], [])
end

stdout, = run_watcher(iterations: 2, in: fixtures_path.join("example_project"))
stdout, = run_watcher(iterations: 1, in: fixtures_path.join("example_project"))

assert_includes(stdout, <<~EXPECTED)
[SYSTEM] mt -- test/example_test.rb
[SOUND] :fail
Watching for changes to source and test files. Press ctrl-c to exit.
Watching for changes to source and test files. Press "q" to quit.
EXPECTED
end

Expand All @@ -103,12 +103,33 @@ def test_watcher_restarts_the_listener_when_a_test_run_is_interrupted
assert_equal(2, thread_count)
end

def test_watcher_exits_when_q_key_is_pressed
stdout, = run_watcher(stdin: "q", in: fixtures_path.join("example_project"))

assert_includes(stdout, "Exiting.")
end

def test_watcher_runs_all_tests_when_enter_key_is_pressed
system_proc do |*args|
puts "[SYSTEM] #{args.join(' ')}"
true
end

stdout, = run_watcher(stdin: "\rq", in: fixtures_path.join("example_project"))

assert_includes(stdout, <<~EXPECTED)
Running all tests...
[SYSTEM] mt
EXPECTED
end

private

class Listener
def initialize(thread, callback)
Thread.new do
thread.call(callback)
thread&.call(callback)
end
end

Expand All @@ -130,13 +151,14 @@ def paused?
end
end

def run_watcher(iterations:, in: ".", extra_args: [])
def run_watcher(iterations: :indefinitely, in: ".", extra_args: [], stdin: nil)
listen_thread = @listen_thread
console = Console.new
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
Dir.chdir(binding.local_variable_get(:in)) do
@watcher = Watcher.new(console:, extra_args:, file_system:, system_proc: @system_proc)
Expand Down

0 comments on commit 7c69c77

Please sign in to comment.