class Autotest
def self.add_hook name, &block
def self.add_hook name, &block HOOKS[name] << block end
def self.parse_options args = ARGV
def self.parse_options args = ARGV require 'optparse' options = { :args => args.dup } OptionParser.new do |opts| opts.banner = <<-BANNER.gsub(/^ /, '') Continuous testing for your ruby app. Autotest automatically tests code that has changed. It assumes the code is in lib, and tests are in test/test_*.rb. Autotest uses plugins to control what happens. You configure plugins with require statements in the .autotest file in your project base directory, and a default configuration for all your projects in the .autotest file in your home directory. Usage: autotest [options] BANNER opts.on "-d", "--debug", "Debug mode, for reporting bugs." do require "pp" options[:debug] = true end opts.on "-f", "--focus", "Focus mode, only run named tests." do options[:focus] = true end opts.on "-v", "--verbose", "Be annoyingly verbose (debugs .autotest)." do options[:verbose] = true end opts.on "-q", "--quiet", "Be quiet." do options[:quiet] = true end opts.on("-r", "--rc CONF", String, "Override path to config file") do |o| options[:rc] = Array(o) end opts.on("-w", "--warnings", "Turn on ruby warnings") do $-w = true end opts.on "-h", "--help", "Show this." do puts opts exit 1 end end.parse! args options end
def self.run args = ARGV
def self.run args = ARGV expander = PathExpander.new args, "**/*.rb" files = expander.process autotest = new parse_options args if autotest.options[:debug] then puts puts "options:" puts pp autotest.options puts "files:" puts pp files puts end autotest.extra_files = files autotest.run end
def add_exception regexp
def add_exception regexp raise "exceptions already compiled" if defined? @exceptions @exception_list << regexp nil end
def add_mapping regexp, prepend = false, &proc
def add_mapping regexp, prepend = false, &proc if prepend then @test_mappings.unshift [regexp, proc] else @test_mappings.push [regexp, proc] end nil end
def add_sigint_handler
def add_sigint_handler trap 'INT' do Process.kill "KILL", @child if @child if self.interrupted then self.wants_to_quit = true else unless hook :interrupt then puts "Interrupt a second time to quit" self.interrupted = true Kernel.sleep 1.5 end raise Interrupt, nil # let the run loop catch it end end end
def add_sigquit_handler
def add_sigquit_handler trap 'QUIT' do restart end end
def all_good
def all_good failures.empty? end
def class_map
def class_map class_map = Hash[*self.find_order.grep(/^test/).map { |f| # TODO: ugly [path_to_classname(f), f] }.flatten] class_map.merge! self.extra_class_map class_map end
def clear_exceptions
def clear_exceptions raise "exceptions already compiled" if defined? @exceptions @exception_list.clear nil end
def clear_mappings
def clear_mappings @test_mappings.clear nil end
def debug
def debug find_files_to_test puts "Known test files:" puts pp files_to_test.keys.sort class_map = self.class_map puts puts "Known class map:" puts pp class_map end
def exceptions
def exceptions unless defined? @exceptions then @exceptions = if @exception_list.empty? then nil else Regexp.union(*@exception_list) end end @exceptions end
def files_matching regexp
def files_matching regexp self.find_order.select { |k| k =~ regexp } end
def find_files
def find_files result = {} targets = if options[:focus] then self.extra_files else self.find_directories + self.extra_files end reset_find_order targets.each do |target| order = [] Find.find target do |f| Find.prune if f =~ self.exceptions Find.prune if f =~ /^\.\/tmp/ # temp dir, used by isolate next unless File.file? f next if f =~ /(swp|~|rej|orig)$/ # temporary/patch files next if f =~ /(,v)$/ # RCS files next if f =~ /\/\.?#/ # Emacs autosave/cvs merge files filename = f.sub(/^\.\//, '') result[filename] = File.stat(filename).mtime rescue next order << filename end self.find_order.push(*order.sort) end result end
def find_files_to_test files = find_files
def find_files_to_test files = find_files updated = files.select { |filename, mtime| self.last_mtime < mtime } # nothing to update or initially run unless updated.empty? || self.last_mtime.to_i == 0 then p updated if options[:verbose] hook :updated, updated end updated.map { |f,m| test_files_for f }.flatten.uniq.each do |filename| self.failures[filename] # creates key with default value self.files_to_test[filename] # creates key with default value end if updated.empty? then nil else files.values.max end end
def get_to_green
def get_to_green begin run_tests wait_for_changes unless all_good end until all_good end
def hook name, *args
def hook name, *args deprecated = { # none currently } if deprecated[name] and not HOOKS[name].empty? then warn "hook #{name} has been deprecated, use #{deprecated[name]}" end HOOKS[name].any? { |plugin| plugin[self, *args] } end
def initialize options
def initialize options # these two are set directly because they're wrapped with # add/remove/clear accessor methods @exception_list = [] @child = nil self.options = options self.extra_class_map = {} self.extra_files = [] self.failures = Hash.new { |h,k| h[k] = Hash.new { |h2,k2| h2[k2] = [] } } self.files_to_test = new_hash_of_arrays reset_find_order self.libs = %w[. lib test].join(File::PATH_SEPARATOR) self.output = $stderr self.prefix = nil self.sleep = 1 self.test_mappings = [] self.test_prefix = "gem 'minitest'" self.testlib = "minitest/autorun" # TODO: rename self.find_directories = ['.'] # file in /lib -> run test in /test self.add_mapping(/^lib\/.*\.rb$/) do |filename, _| possible = File.basename(filename).gsub '_', '_?' files_matching %r%^test/.*#{possible}$% end # file in /test -> run it (ruby & rails styles) self.add_mapping(/^test.*\/(test_.*|.*_test)\.rb$/) do |filename, _| filename end default_configs = [File.expand_path('~/.autotest'), './.autotest'] configs = options[:rc] || default_configs configs.each do |f| load f if File.exist? f end end
def known_files
def known_files unless @known_files then @known_files = Hash[*find_order.map { |f| [f, true] }.flatten] end @known_files end
def make_test_cmd files_to_test
def make_test_cmd files_to_test if options[:debug] then puts "Files to test:" puts pp files_to_test puts end cmds = [] full, partial = reorder(failures).partition { |k,v| v.empty? } unless full.empty? then classes = full.map {|k,v| k}.flatten.uniq classes.unshift testlib classes = classes.join " " cmds << "#{ruby_cmd} -e \"#{test_prefix}; %w[#{classes}].each { |f| require f }\" -- --server #{$$}" end unless partial.empty? then files = partial.map(&:first).sort # no longer a hash because of partition files.select! { |path| File.file? path } # filter out (eval) and the like re = [] partial.each do |path, klasses| klasses.each do |klass,methods| re << /#{klass}##{Regexp.union(methods)}/ end end loader = "%w[#{files.join " "}].each do |f| load f; end" re = Regexp.union(re).to_s.gsub(/-mix/, "").gsub(/'/, ".") cmds << "#{ruby_cmd} -e '#{loader}' -- --server #{$$} -n '/#{re}/'" end cmds.join "#{SEP} " end
def minitest_result file, klass, method, fails, assertions, time
def minitest_result file, klass, method, fails, assertions, time fails.reject! { |fail| Minitest::Skip === fail } unless fails.empty? self.tainted = true self.failures[file][klass] << method end end
def minitest_start
def minitest_start self.failures.clear end
def new_hash_of_arrays
def new_hash_of_arrays Hash.new { |h,k| h[k] = [] } end
def path_to_classname s
def path_to_classname s sep = File::SEPARATOR f = s.sub(/^test#{sep}/, '').sub(/\.rb$/, '').split sep f = f.map { |path| path.split(/_|(\d+)/).map { |seg| seg.capitalize }.join } f = f.map { |path| path =~ /^Test/ ? path : "Test#{path}" } f.join '::' end
def path_to_classname s
def path_to_classname s sep = File::SEPARATOR f = s.sub(/^test#{sep}((\w+)#{sep})?/, '').sub(/\.rb$/, '').split sep f = f.map { |path| path.split(/_|(\d+)/).map { |seg| seg.capitalize }.join } f = f.map { |path| path =~ /Test$/ ? path : "#{path}Test" } f.join '::' end
def remove_exception regexp
def remove_exception regexp raise "exceptions already compiled" if defined? @exceptions @exception_list.delete regexp nil end
def remove_mapping regexp
def remove_mapping regexp @test_mappings.delete_if do |k,v| k == regexp end nil end
def reorder files_to_test
def reorder files_to_test max = files_to_test.size files_to_test.sort_by { |k,v| rand max } end
def rerun_all_tests
def rerun_all_tests reset run_tests hook :all_good if all_good end
def reset
def reset self.files_to_test.clear reset_find_order self.failures.clear self.interrupted = false self.last_mtime = T0 self.tainted = false self.wants_to_quit = false hook :reset end
def reset_find_order
def reset_find_order self.find_order = [] self.known_files = nil end
def restart
def restart Minitest::Server.stop Process.kill "KILL", @child if @child cmd = [$0, *options[:args]] index = $LOAD_PATH.index RbConfig::CONFIG["sitelibdir"] if index then extra = $LOAD_PATH[0...index] cmd = [Gem.ruby, "-I", extra.join(":")] + cmd end puts cmd.join(" ") if options[:verbose] exec(*cmd) end
def ruby
def ruby ruby = ENV['RUBY'] ruby ||= File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name']) ruby.gsub! File::SEPARATOR, File::ALT_SEPARATOR if File::ALT_SEPARATOR return ruby end
def ruby_cmd
def ruby_cmd "#{prefix}#{ruby} -I#{libs}" end
def run
def run hook :initialize hook :post_initialize require "minitest/server" Minitest::Server.run self reset add_sigint_handler self.last_mtime = Time.now if options[:no_full_after_start] self.debug if options[:debug] loop do begin # ^c handler get_to_green if tainted? and not options[:no_full_after_failed] then rerun_all_tests else hook :all_good end wait_for_changes rescue Interrupt break if wants_to_quit reset end end hook :quit puts rescue Exception => err hook(:died, err) or ( warn "Unhandled exception: #{err}" warn err.backtrace.join("\n ") warn "Quitting" ) ensure Minitest::Server.stop end
def run_tests
def run_tests new_mtime = self.find_files_to_test return unless new_mtime self.last_mtime = new_mtime cmd = self.make_test_cmd self.files_to_test return if cmd.empty? hook :run_command, cmd puts cmd unless options[:quiet] system cmd hook :ran_command end
def run_tests
def run_tests hook :run_command new_mtime = self.find_files_to_test return unless new_mtime self.last_mtime = new_mtime begin # TODO: deal with unit_diff and partial test runs later original_argv = ARGV.dup ARGV.clear @child = fork do trap "QUIT", "DEFAULT" trap "INT", "DEFAULT" files_to_test.keys.each do |file| load file end end Process.wait ensure @child = nil ARGV.replace original_argv end hook :ran_command end
def test_files_for filename
def test_files_for filename result = [] self.test_mappings.each do |file_re, proc| if filename =~ file_re then result = [proc.call(filename, $~)]. flatten.sort.uniq.select { |f| known_files[f] } break unless result.empty? end end p :test_file_for => [filename, result.first] if result and options[:debug] output.puts "No tests matched #{filename}" if options[:verbose] and result.empty? return result end
def wait_for_changes
def wait_for_changes hook :waiting Kernel.sleep self.sleep until find_files_to_test end