Skip to content

Commit

Permalink
Add keyboard commands to watch mode (#7)
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 authored Feb 22, 2024
1 parent dedbded commit fc76461
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 fc76461

Please sign in to comment.