Skip to content
Fetching contributors…
Cannot retrieve contributors at this time
executable file 362 lines (301 sloc) 10.4 KB
#!/usr/bin/env ruby
# Spin will speed up your autotest(ish) workflow for Rails.
# Spin preloads your Rails environment for testing, so you don't load the same code over and over and over... Spin works best with an autotest(ish) workflow.
require 'socket'
# This brings in `Dir.tmpdir`
require 'tempfile'
# This lets us hash the parameters we want to include in the filename
# without having to worry about subdirectories, special chars, etc.
require 'digest/md5'
# So we can tell users how much time they're saving by preloading their
# environment.
require 'benchmark'
require 'optparse'
require 'pathname'
SEPARATOR = '|'
def usage
<<-USAGE
Usage: spin serve
spin push <file> <file>...
Spin preloads your Rails environment to speed up your autotest(ish) workflow.
USAGE
end
def socket_file
key = Digest::MD5.hexdigest [Dir.pwd, 'spin-gem'].join
[Dir.tmpdir, key].join('/')
end
def determine_test_framework(force_rspec, force_testunit)
if force_rspec
:rspec
elsif force_testunit
:testunit
elsif defined?(RSpec)
:rspec
else
:testunit
end
end
def disconnect(connection)
connection.print "\0"
connection.close
end
def rails_root(preload)
path = Pathname.pwd
until path.join(preload).file?
return if path.root?
path = path.parent
end
path
end
# ## spin serve
def serve(force_rspec, force_testunit, time, push_results, preload)
root_path = rails_root(preload) and Dir.chdir(root_path)
file = socket_file
Spin.parse_hook_file(root_path) if root_path
# We delete the tmp file for the Unix socket if it already exists. The file
# is scoped to the `pwd`, so if it already exists then it must be from an
# old run of `spin serve` and can be cleaned up.
File.delete(file) if File.exist?(file)
# This socket is how we communicate with `spin push`.
socket = UNIXServer.open(file)
# Trap SIGINT (Ctrl-C) so that we exit cleanly.
trap('SIGINT') {
socket.close
exit
}
ENV['RAILS_ENV'] = 'test' unless ENV['RAILS_ENV']
test_framework = nil
if root_path
sec = Benchmark.realtime {
# We require config/application because that file (typically) loads Rails
# and any Bundler deps, as well as loading the initialization code for
# the app, but it doesn't actually perform the initialization. That happens
# in config/environment.
#
# In my experience that's the best we can do in terms of preloading. Rails
# and the gem dependencies rarely change and so don't need to be reloaded.
# But you can't initialize the application because any non-trivial app will
# involve it's models/controllers, etc. in its initialization, which you
# definitely don't want to preload.
Spin.execute_hook(:before_preload)
require File.expand_path preload.sub('.rb','')
Spin.execute_hook(:after_preload)
# Determine the test framework to use using the passed-in 'force' options
# or else default to checking for defined constants.
test_framework = determine_test_framework(force_rspec, force_testunit)
# Preload RSpec to save some time on each test run
begin
require 'rspec/autorun'
# Tell RSpec it's running with a tty to allow colored output
if RSpec.respond_to?(:configure)
RSpec.configure do |c|
c.tty = true if c.respond_to?(:tty=)
end
end
rescue LoadError
end if test_framework == :rspec
}
# This is the amount of time that you'll save on each subsequent test run.
puts "Preloaded Rails env in #{sec}s..."
else
warn "Could not find #{preload}. Are you running this from the root of a Rails project?"
end
puts "Pushing test results back to push processes" if push_results
loop do
# If we're not going to push the results,
# Trap SIGQUIT (Ctrl+\) and re-run the last files that were
# pushed.
if !push_results
trap('QUIT') do
fork_and_run(@last_files_ran, push_results, test_framework, nil)
# See WAIT below
Process.wait
end
end
# Since `spin push` reconnects each time it has new files for us we just
# need to accept(2) connections from it.
conn = socket.accept
# This should be a list of relative paths to files.
files = conn.gets.chomp
files = files.split(SEPARATOR)
# If spin is started with the time flag we will track total execution so
# you can easily compare it with time rspec spec for example
start = Time.now if time
# If we're not sending results back to the push process, we can disconnect
# it immediately.
disconnect(conn) unless push_results
fork_and_run(files, push_results, test_framework, conn)
# WAIT: We don't want the parent process handling multiple test runs at the same
# time because then we'd need to deal with multiple test databases, and
# that destroys the idea of being simple to use. So we wait(2) until the
# child process has finished running the test.
Process.wait
# If we are tracking time we will output it here after everything has
# finished running
puts "Total execution time was #{Time.now - start} seconds" if start
# Tests have now run. If we were pushing results to a push process, we can
# now disconnect it.
begin
disconnect(conn) if push_results
rescue Errno::EPIPE
# Don't abort if the client already disconnected
end
end
ensure
File.delete(file) if file && File.exist?(file)
end
def fork_and_run(files, push_results, test_framework, conn)
Spin.execute_hook(:before_fork)
# We fork(2) before loading the file so that our pristine preloaded
# environment is untouched. The child process will load whatever code it
# needs to, then it exits and we're back to the baseline preloaded app.
fork do
# To push the test results to the push process instead of having them
# displayed by the server, we reopen $stdout/$stderr to the open
# connection.
tty = files.delete "tty?"
if push_results
$stdout.reopen(conn)
if tty
def $stdout.tty?
true
end
end
$stderr.reopen(conn)
end
Spin.execute_hook(:after_fork)
puts
puts "Loading #{files.inspect}"
# Unfortunately rspec's interface isn't as simple as just requiring the
# test file that you want to run (suddenly test/unit seems like the less
# crazy one!).
if test_framework == :rspec
# We pretend the filepath came in as an argument and duplicate the
# behaviour of the `rspec` binary.
ARGV.push files
else
# We require the full path of the file here in the child process.
files.each { |f| require File.expand_path f }
end
end
@last_files_ran = files
end
# ## spin push
def push(preload)
# The filenames that we will spin up to `spin serve` are passed in as
# arguments.
files_to_load = ARGV
# We reject anything in ARGV that isn't a file that exists. This takes
# care of scripts that specify files like `spin push -r file.rb`. The `-r`
# bit will just be ignored.
#
# We build a string like `file1.rb|file2.rb` and pass it up to the server.
files_to_load = files_to_load.map do |file|
args = file.split(':')
file_name = args.first.to_s
line_number = args.last.to_i
# If the file exists then we can push it up just like it is
file_name = if File.exist?(file_name)
file_name
# kicker-2.5.0 now gives us file names without extensions, so we have to try adding it
elsif File.extname(file_name).empty?
full_file_name = [file_name, 'rb'].join('.')
full_file_name if File.exist?(full_file_name)
end
if line_number > 0
abort "You specified a line number. Only one file can be pushed in this case." if files_to_load.length > 1
"#{file_name}:#{line_number}"
else
file_name
end
end.compact.uniq
if root_path = rails_root(preload)
files_to_load.map! do |file|
Pathname.new(file).expand_path.relative_path_from(root_path).to_s
end
Dir.chdir root_path
end
files_to_load << "tty?" if $stdout.tty?
f = files_to_load.join(SEPARATOR)
abort if f.empty?
puts "Spinning up #{f}"
# This is the other end of the socket that `spin serve` opens. At this point
# `spin serve` will accept(2) our connection.
socket = UNIXSocket.open(socket_file)
# We put the filenames on the socket for the server to read and then load.
socket.puts f
while line = socket.readpartial(100)
break if line[-1,1] == "\0"
print line
end
rescue Errno::ECONNREFUSED, Errno::ENOENT
abort "Connection was refused. Have you started up `spin serve` yet?"
end
module Spin
HOOKS = [:before_fork, :after_fork, :before_preload, :after_preload]
def self.hook(name, &block)
raise unless HOOKS.include?(name)
_hooks(name) << block
end
def self.execute_hook(name)
raise unless HOOKS.include?(name)
_hooks(name).each(&:call)
end
def self.parse_hook_file(root)
file = root.join(".spin.rb")
load(file) if File.exist?(file)
end
private
def self._hooks(name)
@hooks ||= {}
@hooks[name] ||= []
@hooks[name]
end
end
force_rspec = false
force_testunit = false
time = false
push_results = false
preload = "config/application.rb"
options = OptionParser.new do |opts|
opts.banner = usage
opts.separator ""
opts.separator "Server Options:"
opts.on("-I", "--load-path=DIR#{File::PATH_SEPARATOR}DIR", "Appends directory to $LOAD_PATH") do |dirs|
$LOAD_PATH.concat(dirs.split(File::PATH_SEPARATOR))
end
opts.on('--rspec', 'Force the selected test framework to RSpec') do |v|
force_rspec = v
end
opts.on('--test-unit', 'Force the selected test framework to Test::Unit') do |v|
force_testunit = v
end
opts.on('-t', '--time', 'See total execution time for each test run') do |v|
time = true
end
opts.on('--push-results', 'Push test results to the push process') do |v|
push_results = v
end
opts.on('--preload FILE', "Preload this file instead of #{preload}") do |v|
preload = v
end
opts.separator "General Options:"
opts.on('-e', 'Stub to keep kicker happy')
opts.on('-v', '--version', 'Show Version') do
require 'spin/version'
puts Spin::VERSION; exit
end
opts.on('-h', '--help') do
$stderr.puts opts
exit
end
end
options.parse!
subcommand = ARGV.shift
case subcommand
when 'serve' then serve(force_rspec, force_testunit, time, push_results, preload)
when 'push' then push(preload)
else
$stderr.puts options
exit 1
end
Something went wrong with that request. Please try again.