Skip to content

Commit

Permalink
Implement a simple, non-interactive --watch mode (#4)
Browse files Browse the repository at this point in the history
* Impl simple algorithm for finding test file for given path

* WIP

* Rewrite using pattern matching

* Extract loop logic
  • Loading branch information
mattbrictson authored Feb 19, 2024
1 parent 9421d21 commit 369b200
Show file tree
Hide file tree
Showing 12 changed files with 310 additions and 2 deletions.
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Layout/MultilineMethodCallIndentation:
Layout/SpaceAroundEqualsInParameterDefault:
EnforcedStyle: no_space

Lint/EmptyFile:
Exclude:
- "test/fixtures/**/*"

Metrics/AbcSize:
Max: 20
Exclude:
Expand Down
2 changes: 2 additions & 0 deletions lib/mighty_test.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
module MightyTest
autoload :VERSION, "mighty_test/version"
autoload :CLI, "mighty_test/cli"
autoload :FileSystem, "mighty_test/file_system"
autoload :MinitestRunner, "mighty_test/minitest_runner"
autoload :OptionParser, "mighty_test/option_parser"
autoload :TestParser, "mighty_test/test_parser"
autoload :Watcher, "mighty_test/watcher"
end
6 changes: 6 additions & 0 deletions lib/mighty_test/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def run(argv: ARGV)
print_help
elsif options[:version]
puts VERSION
elsif options[:watch]
watch
elsif path_args.grep(/.:\d+$/).any?
run_test_by_line_number
else
Expand All @@ -33,6 +35,10 @@ def print_help
runner.print_help_and_exit!
end

def watch
Watcher.new(extra_args:).run
end

def run_test_by_line_number
path, line = path_args.first.match(/^(.+):(\d+)$/).captures
test_name = TestParser.new(path).test_name_at_line(line.to_i)
Expand Down
16 changes: 16 additions & 0 deletions lib/mighty_test/file_system.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module MightyTest
class FileSystem
def listen(&)
require "listen"
Listen.to(*%w[app lib test].select { |p| Dir.exist?(p) }, relative: true, &).tap(&:start)
end

def find_matching_test_file(path)
return nil unless path && File.exist?(path) && !Dir.exist?(path)
return path if path.match?(%r{^test/.*_test.rb$})

test_path = path[%r{^(?:app|lib)/(.+)\.[^\.]+$}, 1].then { "test/#{_1}_test.rb" }
test_path if test_path && File.exist?(test_path)
end
end
end
6 changes: 4 additions & 2 deletions lib/mighty_test/option_parser.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
require "optparse"

module MightyTest
class OptionParser
def initialize
require "optparse"

@parser = ::OptionParser.new do |op|
op.require_exact = true
op.banner = <<~BANNER
Usage: mt <test file>...
mt --watch
BANNER

op.on("--watch") { options[:watch] = true }
op.on("-h", "--help") { options[:help] = true }
op.on("--version") { options[:version] = true }
end
Expand Down
71 changes: 71 additions & 0 deletions lib/mighty_test/watcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require "concurrent"

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))
@event = Concurrent::MVar.new
@extra_args = extra_args
@file_system = file_system
@system_proc = system_proc
end

def run(iterations: :indefinitely)
start_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, :pass | :fail]
puts WATCHING_FOR_CHANGES
end
end
ensure
listener&.stop
end

private

attr_reader :extra_args, :file_system, :listener, :system_proc

def mt(*test_paths)
success = system_proc.call("mt", *extra_args, "--", *test_paths.flatten)
post_event(:tests_completed, success ? :pass : :fail)
rescue Interrupt
# Pressing ctrl-c kills the fs_event background process, so we have to manually restart it.
restart_listener
end

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

@listener = file_system.listen do |modified, added, _removed|
# Pause listener so that subsequent changes are queued up while we are running the tests
listener.pause unless listener.stopped?

test_paths = [*modified, *added].filter_map do |path|
file_system.find_matching_test_file(path)
end

post_event(:file_system_changed, test_paths.uniq)
end
end
alias restart_listener start_listener

def loop_for(iterations, &)
iterations == :indefinitely ? loop(&) : iterations.times(&)
end

def await_next_event
listener.start if listener.paused?
@event.take
end

def post_event(*event)
@event.put(event)
end
end
end
2 changes: 2 additions & 0 deletions mighty_test.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

# Runtime dependencies
spec.add_dependency "concurrent-ruby", "~> 1.1"
spec.add_dependency "listen", "~> 3.5"
spec.add_dependency "minitest", "~> 5.15"
spec.add_dependency "minitest-fail-fast", "~> 0.1.0"
spec.add_dependency "minitest-focus", "~> 1.4"
Expand Down
Empty file.
Empty file.
22 changes: 22 additions & 0 deletions test/integration/mt_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ def test_mt_runs_no_tests_if_line_number_doesnt_match
refute_match(/FailingTest/, result.stdout)
end

def test_mt_runs_watch_mode_that_executes_tests_when_files_change
project_dir = fixtures_path.join("example_project")
stdout, = capture_subprocess_io do
# Start mt --watch in the background
pid = spawn(*%w[bundle exec mt --watch --verbose], chdir: project_dir)

# mt needs time to launch and start its file system listener
sleep 1

# Touch a file and wait for mt --watch to detect the change and run the corresponding test
FileUtils.touch project_dir.join("lib/example.rb")
sleep 1

# OK, we're done here. Tell mt --watch to exit.
Process.kill(:INT, pid)
end

assert_includes(stdout, "Watching for changes to source and test files.")
assert_match(/ExampleTest/, stdout)
assert_match(/\d runs, \d assertions, 0 failures, 0 errors/, stdout)
end

private

def bundle_exec_mt(argv:, env: { "CI" => nil }, chdir: nil, raise_on_failure: true)
Expand Down
50 changes: 50 additions & 0 deletions test/mighty_test/file_system_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
require "test_helper"

module MightyTest
class FileSystemTest < Minitest::Test
include FixturesPath

def test_find_matching_test_file_returns_nil_for_nil_path
assert_nil find_matching_test_file(nil)
end

def test_find_matching_test_file_returns_nil_for_non_existent_path
assert_nil find_matching_test_file("path/to/nowhere.rb")
end

def test_find_matching_test_file_returns_nil_for_directory_path
assert_nil find_matching_test_file("lib/example", in: fixtures_path.join("example_project"))
end

def test_find_matching_test_file_returns_nil_for_path_with_no_corresponding_test
assert_nil find_matching_test_file("lib/example/version.rb", in: fixtures_path.join("example_project"))
end

def test_find_matching_test_file_returns_nil_for_a_test_support_file
assert_nil find_matching_test_file("test/test_helper.rb", in: fixtures_path.join("example_project"))
end

def test_find_matching_test_file_returns_argument_if_it_is_already_a_test
test_path = find_matching_test_file("test/example_test.rb", in: fixtures_path.join("example_project"))
assert_equal("test/example_test.rb", test_path)
end

def test_find_matching_test_file_returns_matching_test_given_an_implementation_path_in_a_gem_project
test_path = find_matching_test_file("lib/example.rb", in: fixtures_path.join("example_project"))
assert_equal("test/example_test.rb", test_path)
end

def test_find_matching_test_file_returns_matching_test_given_a_model_path_in_a_rails_project
test_path = find_matching_test_file("app/models/user.rb", in: fixtures_path.join("rails_project"))
assert_equal("test/models/user_test.rb", test_path)
end

private

def find_matching_test_file(path, in: ".")
Dir.chdir(binding.local_variable_get(:in)) do
FileSystem.new.find_matching_test_file(path)
end
end
end
end
133 changes: 133 additions & 0 deletions test/mighty_test/watcher_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
require "test_helper"

module MightyTest
class WatcherTest < Minitest::Test
include FixturesPath

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

stdout, = run_watcher(iterations: 1, 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

stdout, = run_watcher(iterations: 1, 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

stdout, = run_watcher(iterations: 1, extra_args: ["--fail-fast"], in: fixtures_path.join("example_project"))

assert_includes(stdout, "[SYSTEM] mt --fail-fast -- test/example_test.rb\n")
end

def test_watcher_prints_a_status_message_after_successful_test_run
system_proc do |*args|
puts "[SYSTEM] #{args.join(' ')}"
true
end
listen_thread do |callback|
callback.call(["test/example_test.rb"], [], [])
end

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

assert_includes(stdout, <<~EXPECTED)
[SYSTEM] mt -- test/example_test.rb
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
system_proc do |*args|
puts "[SYSTEM] #{args.join(' ')}"
false
end
listen_thread do |callback|
callback.call(["test/example_test.rb"], [], [])
end

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

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

def test_watcher_restarts_the_listener_when_a_test_run_is_interrupted
thread_count = 0
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)
end

private

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

def start
end

def stop
end

def pause
end

def stopped?
false
end

def paused?
false
end
end

def run_watcher(iterations:, in: ".", extra_args: [])
listen_thread = @listen_thread
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.run(iterations:)
end
end
end

def listen_thread(&thread)
@listen_thread = thread
end

def system_proc(&proc)
@system_proc = proc
end
end
end

0 comments on commit 369b200

Please sign in to comment.