/
spin
executable file
·275 lines (231 loc) · 8.35 KB
/
spin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
#!/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'
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
# ## spin serve
def serve(force_rspec, force_testunit, time, push_results)
file = socket_file
# 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)
ENV['RAILS_ENV'] = 'test' unless ENV['RAILS_ENV']
test_framework = nil
if File.exist? 'config/application.rb'
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.
require File.expand_path 'config/application'
# 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/rails'
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 config/application.rb. 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
end
def fork_and_run(files, push_results, test_framework, conn)
# 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.
if push_results
$stdout.reopen(conn)
$stderr.reopen(conn)
end
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
# 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.
f = files_to_load.collect do |f|
f = f.split(':')[0].to_s
if File.exist?(f)
f # default behavior
elsif File.extname(f).length == 0 #file without extension
f = "#{f}.rb" # try .rb extension
if File.exist?(f)
f
else
nil #fallback to default behavior
end
end
end.compact.uniq.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
abort "Connection was refused. Have you started up `spin serve` yet?"
end
force_rspec = false
force_testunit = false
time = false
push_results = false
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.separator "General Options:"
opts.on('-e', 'Stub to keep kicker happy')
opts.on('-h', '--help') do
$stderr.puts opts
exit 1
end
end
options.parse!
subcommand = ARGV.shift
case subcommand
when 'serve' then serve(force_rspec, force_testunit, time, push_results)
when 'push' then push
else
$stderr.puts options
exit 1
end