Skip to content

Commit

Permalink
Improve console feedback and add sound effects to --watch mode (#5)
Browse files Browse the repository at this point in the history
* Improve console feedback and add sound effects to --watch mode

This commit makes the following improvements to `mt --watch`:

- The console is cleared just before running tests. This makes it easier
  to notice when a change has been detected and tests are starting. It
  also makes the output from the latest test run easy to distinguish,
  since it is no longer mixed in with previous runs.
- On macOS, two different sounds are played at the conclusion of the
  test run, depending on whether the tests passed or failed. This makes
  it easy to tell the result of a test while doing TDD without having to
  even glance at the console.

The clear screen and sound playback functions have been extracted into a
`Console` class. They are only enabled when running `mt` with a TTY.
Sound playback is only enabled on macOS.

* Mock .tty? to ensure proper test setup
  • Loading branch information
mattbrictson authored Feb 20, 2024
1 parent 369b200 commit 5d80649
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 7 deletions.
1 change: 1 addition & 0 deletions lib/mighty_test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module MightyTest
autoload :VERSION, "mighty_test/version"
autoload :CLI, "mighty_test/cli"
autoload :Console, "mighty_test/console"
autoload :FileSystem, "mighty_test/file_system"
autoload :MinitestRunner, "mighty_test/minitest_runner"
autoload :OptionParser, "mighty_test/option_parser"
Expand Down
51 changes: 51 additions & 0 deletions lib/mighty_test/console.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require "io/console"

module MightyTest
class Console
def initialize(sound_player: "/usr/bin/afplay", sound_paths: SOUNDS)
@sound_player = sound_player
@sound_paths = sound_paths
end

def clear
return false unless tty?

$stdout.clear_screen
true
end

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

paths = sound_paths.fetch(name) { raise ArgumentError, "Unknown sound name #{name}" }
path = paths.find { |p| File.exist?(p) }
return false unless path && File.executable?(sound_player)

thread = Thread.new { system(sound_player, path) }
thread.join if wait
true
end

private

# rubocop:disable Layout/LineLength
SOUNDS = {
pass: %w[
/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Milestone-EncoreInfinitum.caf
/System/Library/Sounds/Glass.aiff
],
fail: %w[
/System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Rebound-EncoreInfinitum.caf
/System/Library/Sounds/Bottle.aiff
]
}.freeze
private_constant :SOUNDS
# rubocop:enable Layout/LineLength

attr_reader :sound_player, :sound_paths

def tty?
$stdout.respond_to?(:tty?) && $stdout.tty?
end
end
end
13 changes: 9 additions & 4 deletions lib/mighty_test/watcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ 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))
def initialize(console: Console.new, extra_args: [], file_system: FileSystem.new, system_proc: method(:system))
@event = Concurrent::MVar.new
@console = console
@extra_args = extra_args
@file_system = file_system
@system_proc = system_proc
Expand All @@ -19,19 +20,23 @@ def run(iterations: :indefinitely)
case await_next_event
in [:file_system_changed, paths]
mt(*paths) if paths.any?
in [:tests_completed, :pass | :fail]
puts WATCHING_FOR_CHANGES
in [:tests_completed, status]
console.play_sound(status)
puts "\n#{WATCHING_FOR_CHANGES}"
end
end
ensure
puts "\nExiting."
listener&.stop
end

private

attr_reader :extra_args, :file_system, :listener, :system_proc
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)
rescue Interrupt
Expand Down
67 changes: 67 additions & 0 deletions test/mighty_test/console_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require "test_helper"

module MightyTest
class ConsoleTest < Minitest::Test
def test_clear_returns_false_if_not_tty
result = nil
capture_io { result = Console.new.clear }
refute result
end

def test_clear_clears_the_screen_and_returns_true_and_if_tty
result = nil
stdout, = capture_io do
$stdout.define_singleton_method(:tty?) { true }
$stdout.define_singleton_method(:clear_screen) { print "clear!" }
result = Console.new.clear
end

assert result
assert_equal "clear!", stdout
end

def test_play_sound_returns_false_if_not_tty
result = nil
capture_io { result = Console.new.play_sound(:pass) }
refute result
end

def test_play_sound_returns_false_if_player_is_not_executable
result = nil
capture_io do
$stdout.define_singleton_method(:tty?) { true }
console = Console.new(sound_player: "/path/to/nothing")
result = console.play_sound(:pass)
end
refute result
end

def test_play_sound_returns_false_if_sound_files_are_missing
result = nil
capture_io do
$stdout.define_singleton_method(:tty?) { true }
console = Console.new(sound_player: "/bin/echo", sound_paths: { pass: ["/path/to/nothing"] })
result = console.play_sound(:pass)
end
refute result
end

def test_play_sound_raises_argument_error_if_invalid_sound_name_is_specified
capture_io do
$stdout.define_singleton_method(:tty?) { true }
assert_raises(ArgumentError) { Console.new.play_sound(:whatever) }
end
end

def test_play_sound_calls_sound_player_with_matching_sound_path
result = nil
stdout, = capture_subprocess_io do
$stdout.define_singleton_method(:tty?) { true }
console = Console.new(sound_player: "/bin/echo", sound_paths: { pass: [__FILE__] })
result = console.play_sound(:pass, wait: true)
end
assert result
assert_equal __FILE__, stdout.chomp
end
end
end
29 changes: 26 additions & 3 deletions test/mighty_test/watcher_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,23 @@ def test_watcher_passes_extra_args_through_to_mt_command
assert_includes(stdout, "[SYSTEM] mt --fail-fast -- test/example_test.rb\n")
end

def test_watcher_prints_a_status_message_after_successful_test_run
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

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

assert_includes(stdout, <<~EXPECTED)
[CLEAR]
test/example_test.rb
[SYSTEM] mt -- test/example_test.rb
EXPECTED
end

def test_watcher_prints_a_status_message_and_plays_a_sound_after_successful_test_run
system_proc do |*args|
puts "[SYSTEM] #{args.join(' ')}"
true
Expand All @@ -50,11 +66,13 @@ def test_watcher_prints_a_status_message_after_successful_test_run

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.
EXPECTED
end

def test_watcher_prints_a_status_message_after_failed_test_run
def test_watcher_prints_a_status_message_and_plays_a_sound_after_failed_test_run
system_proc do |*args|
puts "[SYSTEM] #{args.join(' ')}"
false
Expand All @@ -67,6 +85,8 @@ def test_watcher_prints_a_status_message_after_failed_test_run

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.
EXPECTED
end
Expand Down Expand Up @@ -112,11 +132,14 @@ def paused?

def run_watcher(iterations:, in: ".", extra_args: [])
listen_thread = @listen_thread
console = Console.new
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(extra_args:, file_system:, system_proc: @system_proc)
@watcher = Watcher.new(console:, extra_args:, file_system:, system_proc: @system_proc)
@watcher.run(iterations:)
end
end
Expand Down

0 comments on commit 5d80649

Please sign in to comment.