diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0e23247..e90626dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: - rspec-rails - rspec-ruby - bundler-app + - watchexec-ruby-app steps: - uses: actions/checkout@v4 - name: Set up Ruby diff --git a/bin/build/watchexec b/bin/build/watchexec new file mode 100755 index 00000000..0f836dfb --- /dev/null +++ b/bin/build/watchexec @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ghcr.io/alexb52/slim-bullseye-watchexec:latest \ + -f builds/dockerfiles/WatchexecSlimBullseye \ + --push . diff --git a/bin/test/watchexec-ruby-app b/bin/test/watchexec-ruby-app new file mode 100755 index 00000000..689edf80 --- /dev/null +++ b/bin/test/watchexec-ruby-app @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +bundle install +bundle exec rake build +# cp -R features/support features/watchexec-ruby-app/retest +ls -t pkg | head -n1 | xargs -I {} mv pkg/{} features/watchexec-ruby-app/retest.gem +docker compose -f features/watchexec-ruby-app/docker-compose.yml up --build --exit-code-from retest diff --git a/builds/dockerfiles/WatchexecSlimBullseye b/builds/dockerfiles/WatchexecSlimBullseye new file mode 100644 index 00000000..bacc0569 --- /dev/null +++ b/builds/dockerfiles/WatchexecSlimBullseye @@ -0,0 +1,12 @@ +# Stage 1: Build watchexec with Rust +FROM rust:1.83.0-slim-bullseye AS rust-builder + +# Install necessary dependencies for Rust +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git + +# Install watchexec +RUN cargo install watchexec-cli + +# Verify installation +RUN watchexec --version diff --git a/features/watchexec-ruby-app/.gitignore b/features/watchexec-ruby-app/.gitignore new file mode 100644 index 00000000..4e317b77 --- /dev/null +++ b/features/watchexec-ruby-app/.gitignore @@ -0,0 +1,3 @@ +.ruby-version +retest.gem +/tmp/* \ No newline at end of file diff --git a/features/watchexec-ruby-app/Dockerfile b/features/watchexec-ruby-app/Dockerfile new file mode 100644 index 00000000..a8964376 --- /dev/null +++ b/features/watchexec-ruby-app/Dockerfile @@ -0,0 +1,33 @@ +FROM ruby:2.7-slim-bullseye + +# Install necessary dependencies +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Copy watchexec from the Rust stage +COPY --from=ghcr.io/alexb52/slim-bullseye-watchexec:latest /usr/local/cargo/bin/watchexec /usr/local/bin/watchexec + +# Verify watchexec installation in the final image +RUN watchexec --version + +# throw errors if Gemfile has been modified since Gemfile.lock +RUN bundle config --global frozen 1 + +WORKDIR /usr/src/app + +ENV LANG C.UTF-8 +ENV BUNDLER_VERSION 2.1 +ENV GEM_HOME="/usr/local/bundle" +ENV PATH $GEM_HOME/bin:$GEM_HOME/gems/bin:$PATH + +COPY Gemfile Gemfile.lock retest.gem ./ +RUN gem update --system 3.2.3 +RUN gem install bundler -v 2.1.4 +RUN bundle config --delete frozen +RUN bundle install +RUN gem install retest.gem + +COPY . /usr/src/app + +CMD ["retest", "--ruby"] \ No newline at end of file diff --git a/features/watchexec-ruby-app/Gemfile b/features/watchexec-ruby-app/Gemfile new file mode 100644 index 00000000..d0c3b25a --- /dev/null +++ b/features/watchexec-ruby-app/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'minitest', '~> 5.4' +gem 'byebug' \ No newline at end of file diff --git a/features/watchexec-ruby-app/Gemfile.lock b/features/watchexec-ruby-app/Gemfile.lock new file mode 100644 index 00000000..fd16f24d --- /dev/null +++ b/features/watchexec-ruby-app/Gemfile.lock @@ -0,0 +1,15 @@ +GEM + remote: https://rubygems.org/ + specs: + byebug (11.1.3) + minitest (5.14.0) + +PLATFORMS + ruby + +DEPENDENCIES + byebug + minitest (~> 5.4) + +BUNDLED WITH + 2.4.21 diff --git a/features/watchexec-ruby-app/LICENSE b/features/watchexec-ruby-app/LICENSE new file mode 100644 index 00000000..7d5afaec --- /dev/null +++ b/features/watchexec-ruby-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Alexandre Barret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/features/watchexec-ruby-app/README.md b/features/watchexec-ruby-app/README.md new file mode 100644 index 00000000..b5fbae12 --- /dev/null +++ b/features/watchexec-ruby-app/README.md @@ -0,0 +1,23 @@ +# 99 Bottles + +## Installing Ruby + +### Windows + +There's an installer, it's easy. +http://rubyinstaller.org/ + +### Mac + +Newer macs ship with a usable version of Ruby. + +Try `ruby -v` in a terminal window, and if it's 1.9.x or 2.x you're fine. + +http://www.railstutorial.org/book/beginning#sec-install_ruby +http://tutorials.jumpstartlab.com/topics/environment/environment.html +http://docs.railsbridge.org/installfest/macintosh + +### Linux + +Ubuntu: http://docs.railsbridge.org/installfest/linux +https://www.ruby-lang.org/en/installation/ diff --git a/features/watchexec-ruby-app/docker-compose.yml b/features/watchexec-ruby-app/docker-compose.yml new file mode 100644 index 00000000..c474455f --- /dev/null +++ b/features/watchexec-ruby-app/docker-compose.yml @@ -0,0 +1,9 @@ +services: + retest: + build: . + volumes: + - .:/usr/src/app + environment: + - DEFAULT_SLEEP_SECONDS=1 + - LAUNCH_SLEEP_SECONDS=1.5 + command: ruby retest/retest_test.rb diff --git a/features/watchexec-ruby-app/lib/bottles.rb b/features/watchexec-ruby-app/lib/bottles.rb new file mode 100644 index 00000000..48878942 --- /dev/null +++ b/features/watchexec-ruby-app/lib/bottles.rb @@ -0,0 +1,119 @@ +class CountdownSong + attr_reader :verse_template, :max, :min + + def initialize(verse_template:, max: 999999, min: 0) + @verse_template = verse_template + @max, @min = max, min + end + + def song + verses(max, min) + end + + def verses(upper, lower) + upper.downto(lower).collect {|i| verse(i)}.join("\n") + end + + def verse(number) + verse_template.lyrics(number) + end +end + + +class BottleVerse + def self.lyrics(number) + new(BottleNumber.for(number)).lyrics + end + + attr_reader :bottle_number + + def initialize(bottle_number) + @bottle_number = bottle_number + end + + def lyrics + "#{bottle_number} of beer on the wall, ".capitalize + + "#{bottle_number} of beer.\n" + + "#{bottle_number.action}, " + + "#{bottle_number.successor} of beer on the wall.\n" + end +end + + +class BottleNumber + def self.for(number) + case number + when 0 + BottleNumber0 + when 1 + BottleNumber1 + when 6 + BottleNumber6 + else + BottleNumber + end.new(number) + end + + attr_reader :number + def initialize(number) + @number = number + end + + def to_s + "#{quantity} #{container}" + end + + def quantity + number.to_s + end + + def container + "bottles" + end + + def action + "Take #{pronoun} down and pass it around" + end + + def pronoun + "one" + end + + def successor + BottleNumber.for(number - 1) + end +end + +class BottleNumber0 < BottleNumber + def quantity + "no more" + end + + def action + "Go to the store and buy some more" + end + + def successor + BottleNumber.for(99) + end +end + +class BottleNumber1 < BottleNumber + def container + "bottle" + end + + def pronoun + "it" + end +end + +class BottleNumber6 < BottleNumber + def quantity + "1" + end + + def container + "six-pack" + end +end diff --git a/features/watchexec-ruby-app/retest/retest_test.rb b/features/watchexec-ruby-app/retest/retest_test.rb new file mode 100644 index 00000000..91b8899e --- /dev/null +++ b/features/watchexec-ruby-app/retest/retest_test.rb @@ -0,0 +1,16 @@ +require 'retest' +require 'byebug' +require_relative 'support/test_helper' +require 'minitest/autorun' +require_relative 'retest_test/file_changes_test' +require_relative 'retest_test/setup_test' +require_relative 'retest_test/matching_unmatching_command_test' + +$stdout.sync = true + +include FileHelper +include OutputHelper + +module WatchexecRuby + COMMAND = 'retest' +end diff --git a/features/watchexec-ruby-app/retest/retest_test/file_changes_test.rb b/features/watchexec-ruby-app/retest/retest_test/file_changes_test.rb new file mode 100644 index 00000000..0ae71497 --- /dev/null +++ b/features/watchexec-ruby-app/retest/retest_test/file_changes_test.rb @@ -0,0 +1,87 @@ +module WatchexecRuby + class FileChangesTest < Minitest::Test + def teardown + end_retest + end + + def test_start_retest + launch_retest(COMMAND) + + assert_match <<~EXPECTED, read_output + Setup identified: [RUBY]. Using command: 'bundle exec ruby ' + Watcher: [WATCHEXEC] + Launching Retest... + Ready to refactor! You can make file changes now + EXPECTED + end + + def test_modifying_existing_file + launch_retest(COMMAND) + + modify_file('lib/bottles.rb') + + read_output do |output| + assert_match "Test file: test/bottles_test.rb", output + assert_match "12 runs, 12 assertions, 0 failures, 0 errors, 0 skips", output + end + end + + def test_modifying_existing_test_file + launch_retest(COMMAND) + + modify_file('test/bottles_test.rb') + + read_output do |output| + assert_match "Test file: test/bottles_test.rb", output + assert_match "12 runs, 12 assertions, 0 failures, 0 errors, 0 skips", output + end + end + + def test_creating_a_new_test_file + launch_retest(COMMAND) + + create_file 'foo_test.rb' + + assert_match "Test file: foo_test.rb", read_output + + ensure + delete_file 'foo_test.rb' + end + + def test_creating_a_new_file + launch_retest(COMMAND) + + create_file 'foo.rb' + assert_match <<~EXPECTED, read_output + FileNotFound - Retest could not find a matching test file to run. + EXPECTED + + create_file 'foo_test.rb' + assert_match "Test file: foo_test.rb", read_output + + modify_file('lib/bottles.rb') + assert_match "Test file: test/bottles_test.rb", read_output + + modify_file('foo.rb') + assert_match "Test file: foo_test.rb", read_output + + ensure + delete_file 'foo.rb' + delete_file 'foo_test.rb' + end + + def test_untracked_file + create_file 'foo.rb', should_sleep: false + create_file 'foo_test.rb', should_sleep: false + + launch_retest(COMMAND) + + modify_file 'foo.rb' + assert_match "Test file: foo_test.rb", read_output + + ensure + delete_file 'foo.rb' + delete_file 'foo_test.rb' + end + end +end \ No newline at end of file diff --git a/features/watchexec-ruby-app/retest/retest_test/matching_unmatching_command_test.rb b/features/watchexec-ruby-app/retest/retest_test/matching_unmatching_command_test.rb new file mode 100644 index 00000000..5f652354 --- /dev/null +++ b/features/watchexec-ruby-app/retest/retest_test/matching_unmatching_command_test.rb @@ -0,0 +1,38 @@ +module WatchexecRuby + class MatchingUnmatchingCommandTest < Minitest::Test + def teardown + end_retest + end + + def test_displaying_options_on_matching_command + create_file('test/other_bottles_test.rb', should_sleep: false) + + launch_retest(COMMAND) + + create_file 'foo_test.rb' + assert_match "Test file: foo_test.rb", read_output + + modify_file('lib/bottles.rb') + assert_match <<~EXPECTED.chomp, read_output + We found few tests matching: lib/bottles.rb + + [0] - test/bottles_test.rb + [1] - test/other_bottles_test.rb + [2] - none + + Which file do you want to use? + Enter the file number now: + > + EXPECTED + + @input.write "2\n" + wait + + assert_match "Test file: foo_test.rb", read_output + + ensure + delete_file 'foo_test.rb' + delete_file('test/other_bottles_test.rb') + end + end +end diff --git a/features/watchexec-ruby-app/retest/retest_test/setup_test.rb b/features/watchexec-ruby-app/retest/retest_test/setup_test.rb new file mode 100644 index 00000000..5c178d1e --- /dev/null +++ b/features/watchexec-ruby-app/retest/retest_test/setup_test.rb @@ -0,0 +1,7 @@ +module WatchexecRuby + class SetupTest < Minitest::Test + def test_repository_setup + assert_equal :ruby, Retest::Setup.new.type + end + end +end \ No newline at end of file diff --git a/features/watchexec-ruby-app/retest/support/test_helper.rb b/features/watchexec-ruby-app/retest/support/test_helper.rb new file mode 100644 index 00000000..70848e6d --- /dev/null +++ b/features/watchexec-ruby-app/retest/support/test_helper.rb @@ -0,0 +1,78 @@ +# Can be updated to all feature repositories with +# $ bin/test/reset_helpers + +module OutputHelper + def read_output(output = @output) + result = "" + loop do + result += output.read_nonblock(1024) + rescue IO::WaitReadable + break + end + + if block_given? + yield result + else + result + end + end +end + +module FileHelper + def default_sleep_seconds + Float(ENV.fetch('DEFAULT_SLEEP_SECONDS', 1)) + end + + def launch_sleep_seconds + Float(ENV.fetch('LAUNCH_SLEEP_SECONDS', 1.5)) + end + + def wait(sleep_seconds: default_sleep_seconds) + sleep sleep_seconds + end + + def modify_file(path, sleep_seconds: default_sleep_seconds) + return unless File.exist? path + + old_content = File.read(path) + File.open(path, 'w') { |file| file.write old_content } + + sleep sleep_seconds + end + + def create_file(path, should_sleep: true, sleep_seconds: default_sleep_seconds) + File.open(path, "w").tap(&:close) + + sleep sleep_seconds if should_sleep + end + + def delete_file(path) + return unless File.exist? path + + File.delete path + end + + def rename_file(path, new_path) + return unless File.exist? path + + File.rename path, new_path + end +end + +def launch_retest(command, sleep_seconds: launch_sleep_seconds) + require 'open3' + @input, @output, @stderr, @wait_thr = Open3.popen3(command) + @pid = @wait_thr[:pid] + sleep sleep_seconds +end + +def end_retest + @input&.close + @stderr&.close + @output&.close + if @pid + Process.kill('SIGHUP', @pid) + Process.detach(@pid) + end +end + diff --git a/features/watchexec-ruby-app/test/bottles_test.rb b/features/watchexec-ruby-app/test/bottles_test.rb new file mode 100644 index 00000000..0f0e240a --- /dev/null +++ b/features/watchexec-ruby-app/test/bottles_test.rb @@ -0,0 +1,139 @@ +gem 'minitest', '~> 5.4' +require 'minitest/autorun' +require 'minitest/pride' +require_relative '../lib/bottles' + +module VerseRoleTest + def test_plays_verse_role + assert_respond_to @role_player, :lyrics + end +end + +class VerseFake + def self.lyrics(number) + "This is verse #{number}.\n" + end +end + +class VerseFakeTest < Minitest::Test + include VerseRoleTest + + def setup + @role_player = VerseFake + end +end + + +class BottleVerseTest < Minitest::Test + include VerseRoleTest + + def setup + @role_player = BottleVerse + end + + def test_verse_general_rule_upper_bound + expected = + "99 bottles of beer on the wall, " + + "99 bottles of beer.\n" + + "Take one down and pass it around, " + + "98 bottles of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(99) + end + + def test_verse_general_rule_lower_bound + expected = + "3 bottles of beer on the wall, " + + "3 bottles of beer.\n" + + "Take one down and pass it around, " + + "2 bottles of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(3) + end + + def test_verse_7 + expected = + "7 bottles of beer on the wall, " + + "7 bottles of beer.\n" + + "Take one down and pass it around, " + + "1 six-pack of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(7) + end + + def test_verse_6 + expected = + "1 six-pack of beer on the wall, " + + "1 six-pack of beer.\n" + + "Take one down and pass it around, " + + "5 bottles of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(6) + end + + def test_verse_2 + expected = + "2 bottles of beer on the wall, " + + "2 bottles of beer.\n" + + "Take one down and pass it around, " + + "1 bottle of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(2) + end + + def test_verse_1 + expected = + "1 bottle of beer on the wall, " + + "1 bottle of beer.\n" + + "Take it down and pass it around, " + + "no more bottles of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(1) + end + + def test_verse_0 + expected = + "No more bottles of beer on the wall, " + + "no more bottles of beer.\n" + + "Go to the store and buy some more, " + + "99 bottles of beer on the wall.\n" + assert_equal expected, BottleVerse.lyrics(0) + end +end + + +class CountdownSongTest < Minitest::Test + def test_verse + expected = "This is verse 500.\n" + assert_equal( + expected, + CountdownSong.new(verse_template: VerseFake) + .verse(500)) + end + + def test_verses + expected = + "This is verse 99.\n" + + "\n" + + "This is verse 98.\n" + + "\n" + + "This is verse 97.\n" + assert_equal( + expected, + CountdownSong.new(verse_template: VerseFake) + .verses(99, 97)) + end + + def test_song + expected = + "This is verse 47.\n" + + "\n" + + "This is verse 46.\n" + + "\n" + + "This is verse 45.\n" + + "\n" + + "This is verse 44.\n" + + "\n" + + "This is verse 43.\n" + assert_equal( + expected, + CountdownSong.new(verse_template: VerseFake, + max: 47, + min: 43) + .song) + end +end diff --git a/features/watchexec-ruby-app/watchexec b/features/watchexec-ruby-app/watchexec new file mode 100755 index 00000000..1b8b1738 Binary files /dev/null and b/features/watchexec-ruby-app/watchexec differ diff --git a/lib/retest/watcher.rb b/lib/retest/watcher.rb index a782023a..3850dbd9 100644 --- a/lib/retest/watcher.rb +++ b/lib/retest/watcher.rb @@ -41,48 +41,81 @@ def self.installed? end def self.watch(dir:, extensions:, polling: false) - require 'open3' command = "watchexec --exts #{extensions.join(',')} -w #{dir} --emit-events-to stdio --no-meta --only-emit-events" + files = VersionControl.files(extensions: extensions).zip([]).to_h - # watch_rd, watch_wr = IO.pipe - # pid = Process.spawn(command, out: watch_wr) - # at_exit do - # Process.kill("TERM", pid) if pid - # watch_rd.close - # watch_wr.close - # end + watch_rd, watch_wr = IO.pipe + pid = Process.spawn(command, out: watch_wr) + at_exit do + Process.kill("TERM", pid) if pid + watch_rd.close + watch_wr.close + end Thread.new do - files = VersionControl.files(extensions: extensions).zip([]).to_h - - Open3.popen3(command) do |stdin, stdout, stderr, wait_thr| - loop do - ready = IO.select([stdout]) - readable_connections = ready[0] - readable_connections.each do |conn| - data = conn.readpartial(4096) - change = /^(?:create|remove|rename|modify):(?.*)/.match(data.strip) - if change - modified, added, removed = result = [[], [], []] - path = Pathname(change[:path]).relative_path_from(Dir.pwd).to_s - file_exist = File.exist?(path) - file_cached = files.key?(path) - if file_exist && file_cached - modified << path - elsif file_exist && !file_cached - added << path - files[path] = nil - elsif !file_exist && file_cached - removed << path - files.delete(path) - end - - yield result - end + loop do + ready = IO.select([watch_rd]) + readable_connections = ready[0] + readable_connections.each do |conn| + data = conn.readpartial(4096) + change = /^(?:create|remove|rename|modify):(?.*)/.match(data.strip) + + next unless change + + path = Pathname(change[:path]).relative_path_from(Dir.pwd).to_s + file_exist = File.exist?(path) + file_cached = files.key?(path) + + modified, added, removed = result = [[], [], []] + if file_exist && file_cached + modified << path + elsif file_exist && !file_cached + added << path + files[path] = nil + elsif !file_exist && file_cached + removed << path + files.delete(path) end + + yield result end end end + + # require 'open3' + # Thread.new do + # files = VersionControl.files(extensions: extensions).zip([]).to_h + + # Open3.popen3(command) do |stdin, stdout, stderr, wait_thr| + # loop do + # ready = IO.select([stdout]) + # readable_connections = ready[0] + # readable_connections.each do |conn| + # data = conn.readpartial(4096) + # change = /^(?:create|remove|rename|modify):(?.*)/.match(data.strip) + + # next unless change + + # path = Pathname(change[:path]).relative_path_from(Dir.pwd).to_s + # file_exist = File.exist?(path) + # file_cached = files.key?(path) + + # modified, added, removed = result = [[], [], []] + # if file_exist && file_cached + # modified << path + # elsif file_exist && !file_cached + # added << path + # files[path] = nil + # elsif !file_exist && file_cached + # removed << path + # files.delete(path) + # end + + # yield result + # end + # end + # end + # end end end end