├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin └── spin ├── lib ├── spin.rb └── spin │ ├── cli.rb │ ├── hooks.rb │ ├── logger.rb │ └── version.rb ├── spec └── integration_spec.rb └── spin.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | docs/ 3 | spec/tmp 4 | tags 5 | .rvmrc 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.8.7 4 | - 1.9.2 5 | - 1.9.3 6 | - 2.0.0 7 | notifications: 8 | email: 9 | - shatrov@me.com 10 | matrix: 11 | allow_failures: 12 | - rvm: 1.8.7 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Master branch 2 | 3 | ## Version 0.7.1 (Aug 19, 2013) 4 | 5 | * fixed capability with MRI 2.0 [Todd Mazierski and Jonathan del Strother] 6 | 7 | ## Version 0.7.0 (Jul 23, 2013) 8 | 9 | * kill all children in tests [Michael Grosser] 10 | * Fix the case where file_name is nil [Tyson Tate] 11 | * Short aliases for serve and push [Kir Shatrov] 12 | * Trailing params for Rspec as well [Marek Rosa] 13 | * Prevent multiple test processes from running [Todd Mazierski] 14 | * Debug via puts replaced with Logger [Todd Mazierski] 15 | * 2 layers of SIGINT handling to Spin server [Todd Mazierski] 16 | 17 | ## Version 0.6.0 (Feb 13, 2013) 18 | 19 | * add -v flag to spin and kick off integration testing [Michael Grosser] 20 | * Options refactoring, namespaces and more tests [Michael Grosser] 21 | * Do not create multidimensional ARGVs [☈king] 22 | 23 | ## Version 0.5.2 (Jul 26, 2012) 24 | 25 | * Don't preload rspec/rails [Jonathan del Strother] 26 | 27 | ## Version 0.5.1 (Jul 26, 2012) 28 | 29 | * Do not fail with missing root or missing .spin [Michael Grosser] 30 | 31 | ## Version 0.5.0 (Jul 24, 2012) 32 | 33 | * Allow spin to run from a subdirectory of the project. [Dylan Thacker-Smith] 34 | * Delete the socket file when spin serve exits. [Dylan Thacker-Smith] 35 | * Adds ability to specify a line number for the RSpec. [Dmitry Koprov] 36 | * Add --preload FILE option to preload whatever people want [Michael Grosser] 37 | * Hooks [Michael Grosser] 38 | * Make connection tty so we preserve colors [Michael Grosser] 39 | 40 | ## Version 0.4.5 (Mar 14, 2012) 41 | 42 | * Fix issues with nil values from v0.4.4 release 43 | 44 | ## Version 0.4.4 (Mar 14, 2012) 45 | 46 | * Refactor spin-push to support kicker-2.5.0 [Vivek Khokhar] 47 | 48 | ## Version 0.4.3 (Feb 4, 2012) 49 | 50 | * Fixes colored output for Rspec users [Brian Helmkamp] 51 | 52 | ## Version 0.4.2 (Dec 14, 2011) 53 | 54 | * Fixes "undefined local variable or method 'conn'" bug [Ben Brinckerhoff 55 | 56 | ## Version 0.4.1 (Dec 13, 2011) 57 | 58 | * Restores compat with kicker 59 | 60 | ## Version 0.4.0 (Dec. 12, 2011) (yanked) 61 | 62 | * Now supports line numbers for RSpec users. [Marek Prihoda] 63 | spin push spec/models/user_spec.rb:25 64 | 65 | ## Version 0.3.0 (Dec. 4, 2011) 66 | 67 | * Stream results back to the client when using --push-results [Ben Brinckerhoff] 68 | 69 | ## Version 0.2.1 (Nov. 27, 2011) 70 | 71 | * RSpec is now preloaded for RSpec users. Shaves up to a few seconds off of each test run. [Marek Prihoda] 72 | 73 | ## Version 0.2.0 (Nov. 19, 2011) 74 | 75 | * Added a -p (--push-results) flag that displays results in the push process. 76 | * Added --test-unit option to force test framwork to Test::Unit. 77 | * Ensure that we don't spin up duplicate files. 78 | 79 | ## Version 0.1.5 (Nov. 15, 2011) 80 | 81 | * Add --time flag to see total execution time. [Mark Mulder] 82 | * Doesn't spin up anything if push received no valid files. (Fixes #13) 83 | 84 | ## Version 0.1.4 (Nov 2, 2011) 85 | 86 | * Adds a -e option stub to keep kicker happy. 87 | 88 | ## Version 0.1.3 (Nov 2, 2011) 89 | 90 | * Adds --rspec option to force test framework to rspec. 91 | * Adds a -I option to append directories to LOAD_PATH. 92 | * Allows multiple files to be pushed at the same time. 93 | 94 | ## Version 0.1.2 (Nov 1, 2011) 95 | 96 | * Ensure that the paths generated for the socket file are valid on Ubuntu. 97 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | spin (0.7.1) 5 | 6 | GEM 7 | remote: http://rubygems.org/ 8 | specs: 9 | diff-lcs (1.2.4) 10 | rake (10.0.4) 11 | rspec (2.13.0) 12 | rspec-core (~> 2.13.0) 13 | rspec-expectations (~> 2.13.0) 14 | rspec-mocks (~> 2.13.0) 15 | rspec-core (2.13.1) 16 | rspec-expectations (2.13.0) 17 | diff-lcs (>= 1.1.3, < 2.0) 18 | rspec-mocks (2.13.1) 19 | 20 | PLATFORMS 21 | ruby 22 | 23 | DEPENDENCIES 24 | rake 25 | rspec (~> 2.13.0) 26 | spin! 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Jesse Storimer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOa AND 17 | NONINFRINGEMENT. IN NO EVENT SaALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spin 2 | ==== 3 | 4 | [![Build Status](https://travis-ci.org/jstorimer/spin.png)](https://travis-ci.org/jstorimer/spin) 5 | 6 | Spin speeds up your Rails testing workflow. 7 | 8 | By preloading your Rails environment in one process and then using fork(2) for each test run you don't load the same code over and over and over... 9 | Spin works with an autotest(ish) workflow. 10 | 11 | Installation 12 | =========== 13 | 14 | Spin is available as a rubygem. 15 | 16 | ``` ruby 17 | gem i spin 18 | ``` 19 | 20 | Spin is a tool for Rails 3 apps. It is compatible with the following testing libraries: 21 | 22 | * any version of test/unit or MiniTest 23 | * RSpec 2.x 24 | 25 | Usage 26 | ===== 27 | 28 | There are two components to Spin, a server and client. The server has to be running for anything interesting to happen. You can start the Spin server from your `Rails.root` with the following command: 29 | 30 | ``` bash 31 | spin serve 32 | ``` 33 | 34 | As soon as the server is running it will be ready to accept from clients. You can use the following command to specify a file for the server to load: 35 | 36 | ``` bash 37 | spin push test/unit/product_test.rb 38 | ``` 39 | 40 | Or push multiple files to be loaded at once: 41 | 42 | ``` bash 43 | spin push test/unit/product_test.rb test/unit/shop_test.rb test/unit/cart_test.rb 44 | ``` 45 | 46 | Or, when using RSpec, run the whole suite: 47 | 48 | ``` bash 49 | spin push spec 50 | ``` 51 | 52 | Running a single RSpec example by adding a line number is also possible, e.g: 53 | 54 | ``` bash 55 | spin push spec/models/user_spec.rb:14 56 | ``` 57 | 58 | If you experience issues with `test_helper.rb` not being available you may need to add your test directory to the load path using the `-I` option: 59 | 60 | ``` bash 61 | spin serve -Itest 62 | ``` 63 | 64 | Send a SIGQUIT to spin serve (`Ctrl+\`) if you want to re-run the last files that were ran via `spin push [files]`. 65 | 66 | ### With Kicker 67 | 68 | As mentioned, this tool works best with an autotest(ish) workflow. I haven't actually used with with `autotest` itself, but it works great with [kicker](http://github.com/alloy/kicker). Here's the suggested workflow for a Rails app: 69 | 70 | 1. Start up the spin server 71 | 72 | ``` bash 73 | spin serve 74 | ``` 75 | 76 | 2. Start up `kicker` using the custom binary option (and any other options you want) 77 | 78 | ``` bash 79 | kicker -r rails -b 'spin push' 80 | ``` 81 | 82 | 3. Faster testing workflow! 83 | 84 | Motivation 85 | ========== 86 | 87 | A few months back I did an experiment. I opened up the source code to my local copy of the ActiveRecord gem. I added a line at the top of `active_record/base` that incremented a counter in Redis each time it was evaluated. After about a week that counter was well above 2000! 88 | 89 | How did I load the ActiveRecord gem over 2000 times in one week? Autotest. I was using it all day while developing. The Rails version that the app was tracking doesn't change very often, yet I had to load the same code over and over again. 90 | 91 | Given that there's no way to compile Ruby code into a faster representation I immediately thought of fork(2). I just need a process to load up Rails and wait around until I need it. When I want to run the tests I just fork(2) that idle process and run the test. Then I only have to load Rails once at the start of my workflow, fork(2) takes care of sharing the code with each child process. 92 | 93 | I threw together the first version of this project in about 20 minutes and noticed an immediate difference in the speed of my testing workflow. Did I mention that I work on a big app? It takes about 10 seconds(!) to load Rails and all of the gem dependencies. With a bit more hacking I was able to get the idle process to load both Rails and my application dependencies, so each test run just initializes the application and loads the files needed for the test run. 94 | 95 | (10 seconds saved per test run) x (2000 test runs per week) = (lots of time saved!) 96 | 97 | ### How is it different from Spork? 98 | 99 | There's another project ([spork](https://github.com/sporkrb/spork)) that aims to solve the same problem, but takes a different approach. 100 | 101 | 1. It's unobtrusive. 102 | 103 | Your application needs to know about Spork, Spin works entirely outside of your application. 104 | 105 | You'll need to add spork to your Gemfile and introduce your `test_helper.rb` to spork. Spork needs to know details about your app's loading process. 106 | 107 | Spin is designed so that your app never has to know about it. You can use Spin to run your tests while the rest of your team doesn't even know that Spin exists. 108 | 109 | 2. It's simple. 110 | 111 | Spin should work out of the box with any Rails app. No custom configuration required. 112 | 113 | 3. It doesn't do any [crazy monkey patching](https://github.com/sporkrb/spork-rails/blob/master/lib/spork/app_framework/rails.rb#L43-80). 114 | 115 | Docs 116 | ============ 117 | 118 | [Rocco](http://rtomayko.github.com/rocco/)-annotated source: 119 | 120 | * [spin](http://jstorimer.github.com/spin/) 121 | * [spin serve](http://jstorimer.github.com/spin/#section-spin_serve) 122 | * [spin push](http://jstorimer.github.com/spin/#section-spin_push) 123 | 124 | Hacking 125 | ======= 126 | 127 | I take pull requests, and it's commit bit, and there are no tests. 128 | 129 | Related Projects 130 | =============== 131 | 132 | If Spin isn't scratching your itch then one of these projects might: 133 | 134 | * [guard-spin](https://github.com/vizjerai/guard-spin) 135 | * [Spork](https://github.com/sporkrb/spork) 136 | * [TestR](https://github.com/sunaku/testr) 137 | * [Zeus](https://github.com/burke/zeus) 138 | * [Spring](https://github.com/jonleighton/spring) 139 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rspec/core/rake_task' 3 | require 'rubygems/specification' 4 | 5 | task :default => :spec 6 | desc "Run specs" 7 | RSpec::Core::RakeTask.new do |t| 8 | t.pattern = FileList['spec/**/*_spec.rb'] 9 | t.rspec_opts = %w(-fs --color) 10 | end 11 | -------------------------------------------------------------------------------- /bin/spin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'spin/cli' 3 | Spin::CLI.run(ARGV) 4 | -------------------------------------------------------------------------------- /lib/spin.rb: -------------------------------------------------------------------------------- 1 | require 'spin/version' 2 | require 'spin/hooks' 3 | require 'spin/logger' 4 | require 'socket' 5 | require 'tempfile' # Dir.tmpdir 6 | # This lets us hash the parameters we want to include in the filename 7 | # without having to worry about subdirectories, special chars, etc. 8 | require 'digest/md5' 9 | # So we can tell users how much time they're saving by preloading their 10 | # environment. 11 | require 'benchmark' 12 | require 'pathname' 13 | 14 | module Spin 15 | extend Spin::Hooks 16 | 17 | PUSH_FILE_SEPARATOR = '|' 18 | ARGS_SEPARATOR = ' -- ' 19 | # Messages written to/read from the self-pipe queue. 20 | SIGQUIT_MESSAGE = 'SIGQUIT' 21 | SIGINT_MESSAGE = 'SIGINT' 22 | 23 | class << self 24 | def serve(options) 25 | ENV['RAILS_ENV'] = 'test' unless ENV['RAILS_ENV'] 26 | 27 | if root_path = rails_root(options[:preload]) 28 | Dir.chdir(root_path) 29 | Spin.parse_hook_file(root_path) 30 | else 31 | logger.warn "Could not find #{options[:preload]}. Are you running this from the root of a Rails project?" 32 | end 33 | 34 | set_server_process_pid 35 | 36 | open_socket do |socket| 37 | preload(options) if root_path 38 | self_read, self_write = IO.pipe 39 | 40 | if options[:push_results] 41 | logger.info "Pushing test results back to push processes" 42 | else 43 | trap('SIGQUIT') { sigquit_handler(self_write) } 44 | end 45 | trap('SIGINT') { sigint_handler(self_write) } 46 | 47 | loop do 48 | readable_io = ready_while do 49 | IO.select([socket, self_read])[0][0] 50 | end 51 | 52 | if readable_io == self_read 53 | # One of our signal handlers has fired 54 | case readable_io.gets.strip 55 | when SIGQUIT_MESSAGE 56 | rerun_last_tests(options) 57 | when SIGINT_MESSAGE 58 | exit_server(socket) 59 | end 60 | else 61 | # The socket must have had a new test written to it 62 | run_pushed_tests(socket, options) 63 | end 64 | end 65 | end 66 | end 67 | 68 | # This method is called when a SIGQUIT ought to be handled. 69 | # 70 | # Given the self-pipe +queue+, adds a SIGQUIT message to it. Message is 71 | # *not* queued if the current process is not the Spin server process (i.e. 72 | # it's a test process). Otherwise, more than one message would be added to 73 | # the queue when Ctrl+\ is pressed. 74 | # 75 | def sigquit_handler(queue) 76 | return unless server_process? 77 | 78 | queue.puts(SIGQUIT_MESSAGE) 79 | end 80 | 81 | # This method is called when a SIGINT ought to be handled. 82 | # 83 | # Given the self-pipe +queue+, adds a SIGINT message to it. Message is 84 | # *not* queued if either of these are true: 85 | # 86 | # 1. The current process is not the Spin server process (i.e. it's a test 87 | # process). Instead, the signal is "bubbled up" by exiting. 88 | # 89 | # 2. The Spin server is not ready for a new command. 90 | # 91 | def sigint_handler(queue) 92 | exit unless server_process? 93 | return unless ready? 94 | 95 | queue.puts(SIGINT_MESSAGE) 96 | end 97 | 98 | def logger 99 | @logger ||= Spin::Logger.new 100 | end 101 | 102 | # Called by the Spin server process to store its process pid. 103 | def set_server_process_pid 104 | $0 = 'spin-server' 105 | @server_process_pid = Process.pid 106 | end 107 | 108 | # Returns +true+ if the current process is the Spin server process. 109 | def server_process? 110 | @server_process_pid == Process.pid 111 | end 112 | 113 | def push(argv, options) 114 | files_to_load = convert_push_arguments_to_files(argv) 115 | 116 | if root_path = rails_root(options[:preload]) 117 | make_files_relative(files_to_load, root_path) 118 | Dir.chdir root_path 119 | end 120 | 121 | files_to_load << "tty?" if $stdout.tty? 122 | 123 | abort if files_to_load.empty? 124 | 125 | logger.info "Spinning up #{files_to_load.join(" ")}" 126 | send_files_to_serve(files_to_load, options[:trailing_pushed_args] || []) 127 | end 128 | 129 | private 130 | 131 | def send_files_to_serve(files_to_load, trailing_args) 132 | # This is the other end of the socket that `spin serve` opens. At this point 133 | # `spin serve` will accept(2) our connection. 134 | socket = UNIXSocket.open(socket_file) 135 | 136 | # We put the filenames on the socket for the server to read and then load. 137 | payload = files_to_load.join(PUSH_FILE_SEPARATOR) 138 | payload += "#{ARGS_SEPARATOR}#{trailing_args.join(PUSH_FILE_SEPARATOR)}" unless trailing_args.empty? 139 | socket.puts payload 140 | 141 | while line = socket.readpartial(100) 142 | break if line[-1,1] == "\0" 143 | print line 144 | end 145 | rescue Errno::ECONNREFUSED, Errno::ENOENT 146 | abort "Connection was refused. Have you started up `spin serve` yet?" 147 | end 148 | 149 | # The filenames that we will spin up to `spin serve` are passed in as 150 | # arguments. 151 | def convert_push_arguments_to_files(argv) 152 | files_to_load = argv 153 | 154 | # We reject anything in ARGV that isn't a file that exists. This takes 155 | # care of scripts that specify files like `spin push -r file.rb`. The `-r` 156 | # bit will just be ignored. 157 | # 158 | # We build a string like `file1.rb|file2.rb` and pass it up to the server. 159 | files_to_load = files_to_load.map do |file| 160 | args = file.split(':') 161 | 162 | file_name = args.first.to_s 163 | line_number = args.last.to_i 164 | 165 | # If the file exists then we can push it up just like it is 166 | file_name = if File.exist?(file_name) 167 | file_name 168 | # kicker-2.5.0 now gives us file names without extensions, so we have to try adding it 169 | elsif File.extname(file_name).empty? 170 | full_file_name = [file_name, 'rb'].join('.') 171 | full_file_name if File.exist?(full_file_name) 172 | end 173 | 174 | if line_number > 0 175 | abort "You specified a line number. Only one file can be pushed in this case." if files_to_load.length > 1 176 | 177 | "#{file_name}:#{line_number}" 178 | else 179 | file_name.to_s 180 | end 181 | end.compact.reject(&:empty?).uniq 182 | end 183 | 184 | def make_files_relative(files_to_load, root_path) 185 | files_to_load.map! do |file| 186 | Pathname.new(file).expand_path.relative_path_from(root_path).to_s 187 | end 188 | end 189 | 190 | def run_pushed_tests(socket, options) 191 | # Since `spin push` reconnects each time it has new files for us we just 192 | # need to accept(2) connections from it. 193 | conn = socket.accept 194 | # This should be a list of relative paths to files. 195 | files = conn.gets.chomp 196 | files, trailing_args = files.split(ARGS_SEPARATOR) 197 | options[:trailing_args] = trailing_args.nil? ? [] : trailing_args.split(PUSH_FILE_SEPARATOR) 198 | files = files.split(PUSH_FILE_SEPARATOR) 199 | 200 | # If spin is started with the time flag we will track total execution so 201 | # you can easily compare it with time rspec spec for example 202 | start = Time.now if options[:time] 203 | 204 | # If we're not sending results back to the push process, we can disconnect 205 | # it immediately. 206 | disconnect(conn) unless options[:push_results] 207 | 208 | fork_and_run(files, conn, options) 209 | 210 | # If we are tracking time we will output it here after everything has 211 | # finished running 212 | logger.info "Total execution time was #{Time.now - start} seconds" if start 213 | 214 | # Tests have now run. If we were pushing results to a push process, we can 215 | # now disconnect it. 216 | begin 217 | disconnect(conn) if options[:push_results] 218 | rescue Errno::EPIPE 219 | # Don't abort if the client already disconnected 220 | end 221 | end 222 | 223 | # Reruns the last tests that were pushed. 224 | def rerun_last_tests(options) 225 | unless @last_files_ran 226 | logger.fatal "Cannot rerun last tests, please push a file to Spin server first" 227 | return 228 | end 229 | 230 | fork_and_run(@last_files_ran, nil, options.merge(:trailing_args => @last_trailing_args_used)) 231 | end 232 | 233 | # Changes Spin server's "ready" state to +true+ while the given +block+ 234 | # executes. Returns the result of the +block+. 235 | def ready_while(&block) 236 | @ready = true 237 | logger.info('Ready') 238 | yield.tap { @ready = false } 239 | end 240 | 241 | # Returns Spin server's "ready" state. If +true+, this indicates that the 242 | # server is available for new tests or commands. 243 | def ready? 244 | @ready 245 | end 246 | 247 | def preload(options) 248 | duration = Benchmark.realtime do 249 | # We require config/application because that file (typically) loads Rails 250 | # and any Bundler deps, as well as loading the initialization code for 251 | # the app, but it doesn't actually perform the initialization. That happens 252 | # in config/environment. 253 | # 254 | # In my experience that's the best we can do in terms of preloading. Rails 255 | # and the gem dependencies rarely change and so don't need to be reloaded. 256 | # But you can't initialize the application because any non-trivial app will 257 | # involve it's models/controllers, etc. in its initialization, which you 258 | # definitely don't want to preload. 259 | execute_hook(:before_preload) 260 | require File.expand_path options[:preload].sub('.rb', '') 261 | execute_hook(:after_preload) 262 | 263 | # Determine the test framework to use using the passed-in 'force' options 264 | # or else default to checking for defined constants. 265 | options[:test_framework] ||= determine_test_framework 266 | 267 | # Preload RSpec to save some time on each test run 268 | if options[:test_framework] == :rspec 269 | begin 270 | require 'rspec/core' 271 | 272 | # Tell RSpec it's running with a tty to allow colored output 273 | if RSpec.respond_to?(:configure) 274 | RSpec.configure do |c| 275 | c.tty = true if c.respond_to?(:tty=) 276 | end 277 | end 278 | rescue LoadError 279 | end 280 | end 281 | end 282 | # This is the amount of time that you'll save on each subsequent test run. 283 | logger.info "Preloaded Rails environment in #{duration.round(2)}s" 284 | end 285 | 286 | # This socket is how we communicate with `spin push`. 287 | # We delete the tmp file for the Unix socket if it already exists. The file 288 | # is scoped to the `pwd`, so if it already exists then it must be from an 289 | # old run of `spin serve` and can be cleaned up. 290 | def open_socket 291 | file = socket_file 292 | File.delete(file) if File.exist?(file) 293 | socket = UNIXServer.open(file) 294 | 295 | yield socket 296 | ensure 297 | File.delete(file) if file && File.exist?(file) 298 | end 299 | 300 | # Exits the server process. 301 | def exit_server(socket) 302 | logger.info "Exiting" 303 | socket.close 304 | exit 305 | end 306 | 307 | def determine_test_framework 308 | if defined?(RSpec) 309 | :rspec 310 | else 311 | :testunit 312 | end 313 | end 314 | 315 | def disconnect(connection) 316 | connection.print "\0" 317 | connection.close 318 | end 319 | 320 | def rails_root(preload) 321 | path = Pathname.pwd 322 | until path.join(preload).file? 323 | return if path.root? 324 | path = path.parent 325 | end 326 | path 327 | end 328 | 329 | def fork_and_run(files, conn, options) 330 | execute_hook(:before_fork) 331 | # We fork(2) before loading the file so that our pristine preloaded 332 | # environment is untouched. The child process will load whatever code it 333 | # needs to, then it exits and we're back to the baseline preloaded app. 334 | fork do 335 | # To push the test results to the push process instead of having them 336 | # displayed by the server, we reopen $stdout/$stderr to the open 337 | # connection. 338 | tty = files.delete "tty?" 339 | if options[:push_results] 340 | $stdout.reopen(conn) 341 | if tty 342 | def $stdout.tty? 343 | true 344 | end 345 | end 346 | $stderr.reopen(conn) 347 | end 348 | 349 | execute_hook(:after_fork) 350 | 351 | logger.info "Loading #{files.inspect}" 352 | 353 | trailing_args = options[:trailing_args] 354 | logger.info "Will run with: #{trailing_args.inspect}" unless trailing_args.empty? 355 | 356 | if options[:test_framework] == :rspec 357 | RSpec::Core::Runner.run(files + trailing_args) 358 | else 359 | # Pass any additional push arguments to the test runner 360 | ARGV.concat trailing_args 361 | # We require the full path of the file here in the child process. 362 | files.each { |f| require File.expand_path f } 363 | end 364 | end 365 | @last_files_ran = files 366 | @last_trailing_args_used = options[:trailing_args] 367 | 368 | # WAIT: We don't want the parent process handling multiple test runs at the same 369 | # time because then we'd need to deal with multiple test databases, and 370 | # that destroys the idea of being simple to use. 371 | Process.wait 372 | end 373 | 374 | def socket_file 375 | key = Digest::MD5.hexdigest [Dir.pwd, 'spin-gem'].join 376 | [Dir.tmpdir, key].join('/') 377 | end 378 | end 379 | end 380 | -------------------------------------------------------------------------------- /lib/spin/cli.rb: -------------------------------------------------------------------------------- 1 | require 'spin' 2 | require 'optparse' 3 | 4 | module Spin 5 | module CLI 6 | class << self 7 | def run(argv) 8 | options = { 9 | :preload => "config/application.rb" 10 | } 11 | 12 | parser = OptionParser.new do |opts| 13 | opts.banner = usage 14 | opts.separator "" 15 | opts.separator "Server Options:" 16 | 17 | opts.on("-I", "--load-path=DIR#{File::PATH_SEPARATOR}DIR", "Appends directory to $LOAD_PATH") do |dirs| 18 | $LOAD_PATH.concat(dirs.split(File::PATH_SEPARATOR)) 19 | end 20 | 21 | opts.on("--rspec", "Force the selected test framework to RSpec") { options[:test_framework] = :rspec } 22 | opts.on("--test-unit", "Force the selected test framework to Test::Unit") { options[:test_framework] = :testunit } 23 | opts.on("-t", "--time", "See total execution time for each test run") { options[:time] = true } 24 | opts.on("--push-results", "Push test results to the push process") { options[:push_results] = true } 25 | opts.on("--preload FILE", "Preload this file instead of #{options[:preload]}") { |v| options[:preload] = v } 26 | opts.separator "General Options:" 27 | opts.on("-e", "Stub to keep kicker happy") 28 | opts.on("-v", "--version", "Show Version") { puts Spin::VERSION; exit } 29 | opts.on("-h", "--help") { $stderr.puts opts; exit } 30 | opts.on('--', 'Separates trailing arguments to be forwarded to the test runner') do |v| 31 | trailing_pushed_args = [] 32 | while opt = ARGV.shift 33 | trailing_pushed_args << opt 34 | end 35 | options[:trailing_pushed_args] = trailing_pushed_args 36 | end 37 | end 38 | parser.parse! 39 | 40 | subcommand = argv.shift 41 | case subcommand 42 | when "serve", "s" 43 | then Spin.serve(options) 44 | when "push", "p" 45 | then Spin.push(argv, options) 46 | else 47 | $stderr.puts parser 48 | exit 1 49 | end 50 | end 51 | 52 | private 53 | 54 | def usage 55 | <<-USAGE.gsub(/^\s{8}/,"") 56 | Usage: spin serve 57 | spin push ... 58 | Spin preloads your Rails environment to speed up your autotest(ish) workflow. 59 | USAGE 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/spin/hooks.rb: -------------------------------------------------------------------------------- 1 | module Spin 2 | module Hooks 3 | HOOKS = [:before_fork, :after_fork, :before_preload, :after_preload] 4 | 5 | def hook(name, &block) 6 | raise unless HOOKS.include?(name) 7 | _hooks(name) << block 8 | end 9 | 10 | def execute_hook(name) 11 | raise unless HOOKS.include?(name) 12 | _hooks(name).each(&:call) 13 | end 14 | 15 | def parse_hook_file(root) 16 | file = root.join(".spin.rb") 17 | load(file) if File.exist?(file) 18 | end 19 | 20 | private 21 | 22 | def _hooks(name) 23 | @hooks ||= {} 24 | @hooks[name] ||= [] 25 | @hooks[name] 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/spin/logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'forwardable' 3 | 4 | module Spin 5 | class Logger 6 | extend Forwardable 7 | 8 | attr_reader :logger 9 | def_delegators :logger, :fatal, 10 | :error, 11 | :warn, 12 | :info, 13 | :debug 14 | 15 | def initialize 16 | @logger = ::Logger.new($stdout) 17 | @logger.level = level 18 | @logger.formatter = formatter 19 | end 20 | 21 | private 22 | 23 | def level 24 | ::Logger::INFO 25 | end 26 | 27 | def formatter 28 | proc { |_, _, _, message| "[#{caller}] #{message}\n" } 29 | end 30 | 31 | # Returns a "Spin" label for log entries, with color, if supported. 32 | def caller 33 | name = "Spin" 34 | $stdout.isatty ? cyan(name) : name 35 | end 36 | 37 | # Uses ANSI escape codes to create cyan-colored output. 38 | def cyan(string) 39 | "\e[36m#{string}\e[0m" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/spin/version.rb: -------------------------------------------------------------------------------- 1 | module Spin 2 | VERSION = "0.7.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | 2 | describe "Spin" do 3 | around do |example| 4 | folder = File.expand_path("../tmp", __FILE__) 5 | `rm -rf #{folder}` 6 | ensure_folder folder 7 | Dir.chdir folder do 8 | example.call 9 | end 10 | `rm -rf #{folder}` 11 | end 12 | 13 | after do 14 | kill_all_threads 15 | kill_all_children 16 | end 17 | 18 | context "with simple setup" do 19 | before do 20 | write "config/application.rb", "$xxx = 1234" 21 | write "test/foo_test.rb", "puts $xxx * 2" 22 | @log_label = "[Spin]" 23 | @default_pushed = "#{@log_label} Spinning up test/foo_test.rb\n" 24 | @preloaded_message = "Preloaded Rails environment in " 25 | end 26 | 27 | it "shows help when no arguments are given" do 28 | spin("", :fail => true).should include("General Options:") 29 | end 30 | 31 | it "can serve and push" do 32 | served, pushed = serve_and_push("", "test/foo_test.rb") 33 | served.should include @preloaded_message 34 | served.should include "2468" 35 | pushed.first.should == @default_pushed 36 | end 37 | 38 | it "can run files without .rb extension" do 39 | served, pushed = serve_and_push("", "test/foo_test") 40 | served.should include @preloaded_message 41 | served.should include "2468" 42 | pushed.first.should == @default_pushed 43 | end 44 | 45 | it "can run multiple times" do 46 | write "test/foo_test.rb", "puts $xxx *= 2" 47 | served, pushed = serve_and_push("", ["test/foo_test.rb", "test/foo_test.rb", "test/foo_test.rb"]) 48 | served.should include @preloaded_message 49 | served.scan("2468").size.should == 3 50 | pushed.size.should == 3 51 | pushed.each{|x| x.should == @default_pushed } 52 | end 53 | 54 | it "can run multiple files at once" do 55 | write "test/bar_test.rb", "puts $xxx / 2" 56 | served, pushed = serve_and_push("", "test/foo_test.rb test/bar_test.rb") 57 | served.should include @preloaded_message 58 | served.should include "2468" 59 | served.should include "617" 60 | pushed.first.should == "#{@log_label} Spinning up test/foo_test.rb test/bar_test.rb\n" 61 | end 62 | 63 | it "complains when the preloaded file cannot be found" do 64 | delete "config/application.rb" 65 | write "test/foo_test.rb", "puts 2468" 66 | served, pushed = serve_and_push("", "test/foo_test.rb") 67 | served.should_not include @preloaded_message 68 | served.should include "Could not find config/application.rb. Are you running" 69 | served.should include "2468" 70 | pushed.first.should == @default_pushed 71 | end 72 | 73 | context "RSpec" do 74 | before do 75 | write "config/application.rb", "module RSpec;end" 76 | end 77 | 78 | it "can run files" do 79 | write "spec/foo_spec.rb", "RSpec.configure{}; puts 'YES'" 80 | served, pushed = serve_and_push("", "spec/foo_spec.rb") 81 | served.should include "YES" 82 | end 83 | 84 | it "can run by line" do 85 | write "spec/foo_spec.rb", <<-RUBY 86 | describe "x" do 87 | it("a"){ puts "AAA" } 88 | it("b"){ puts "BBB" } 89 | it("c"){ puts "CCC" } 90 | end 91 | RUBY 92 | served, pushed = serve_and_push("", "spec/foo_spec.rb:3") 93 | served.should_not include "AAA" 94 | served.should include "BBB" 95 | served.should_not include "CCC" 96 | end 97 | 98 | it "can pass trailing arguments to the spec runner" do 99 | write "spec/foo_spec.rb", <<-RUBY 100 | describe "x" do 101 | it("a"){ puts "AAA" } 102 | it("b"){ puts "BBB" } 103 | it("c"){ puts "CCC" } 104 | end 105 | RUBY 106 | served, pushed = serve_and_push("", ["spec/foo_spec.rb -- --profile"]) 107 | served.should include 'Will run with: ["--profile"]' 108 | served.should include 'Top 3 slowest examples' 109 | end 110 | end 111 | 112 | context "options" do 113 | it "can show current version" do 114 | spin("--version").should =~ /^\d+\.\d+\.\d+/ 115 | end 116 | 117 | it "can show help" do 118 | spin("--help").should include("General Options:") 119 | end 120 | 121 | it "can --push-results" do 122 | served, pushed = serve_and_push("--push-results", "test/foo_test.rb") 123 | served.should include @preloaded_message 124 | served.should_not include "2468" 125 | pushed.first.should include "2468" 126 | end 127 | 128 | it "can --preload a different file" do 129 | write "config/application.rb", "raise" 130 | write "config/environment.rb", "$xxx = 1234" 131 | served, pushed = serve_and_push("--preload config/environment.rb", "test/foo_test.rb") 132 | served.should include @preloaded_message 133 | served.should include "2468" 134 | pushed.first.should == @default_pushed 135 | end 136 | 137 | it "can add load paths via -I" do 138 | write "lib/bar.rb", "puts 'bar'" 139 | write "test/foo_test.rb", "require 'bar'" 140 | served, pushed = serve_and_push("-Itest:lib", "test/foo_test.rb") 141 | served.should include "bar" 142 | pushed.first.should == @default_pushed 143 | end 144 | 145 | it "ignores -e" do 146 | served, pushed = serve_and_push("-e", "test/foo_test.rb -e") 147 | served.should include @preloaded_message 148 | served.should include "2468" 149 | pushed.first.should == @default_pushed 150 | end 151 | 152 | # TODO process never reaches after the fork block with only 1 push command 153 | it "can show total execution time" do 154 | served, pushed = serve_and_push("--time", ["test/foo_test.rb", "test/foo_test.rb"]) 155 | served.should include "Total execution time was 0." 156 | pushed.first.should == @default_pushed 157 | end 158 | 159 | it "can pass trailing arguments to the test runner" do 160 | served, pushed = serve_and_push("", ["test/foo_test.rb -- -n /validates/"]) 161 | served.should include 'Will run with: ["-n", "/validates/"]' 162 | pushed.first.should == @default_pushed 163 | end 164 | end 165 | 166 | context "hooks" do 167 | before do 168 | write "config/application.rb", "$calls << :real_preload" 169 | write "test/calls_test.rb", "puts '>>' + $calls.inspect + '<<'" 170 | end 171 | 172 | it "calls preload hooks in correct order" do 173 | write ".spin.rb", <<-RUBY 174 | $calls = [] 175 | [:before_preload, :after_preload].each do |hook| 176 | Spin.hook(hook) { $calls << hook } 177 | end 178 | RUBY 179 | served, pushed = serve_and_push("--time", "test/calls_test.rb") 180 | served[/>>.*<>[:before_preload, :real_preload, :after_preload]<<" 181 | end 182 | 183 | it "can have multiple hooks" do 184 | write ".spin.rb", <<-RUBY 185 | $calls = [] 186 | Spin.hook(:before_preload) { $calls << :before_preload_1 } 187 | Spin.hook(:before_preload) { $calls << :before_preload_2 } 188 | RUBY 189 | served, pushed = serve_and_push("--time", "test/calls_test.rb") 190 | served[/>>.*<>[:before_preload_1, :before_preload_2, :real_preload]<<" 191 | end 192 | 193 | it "can hook before/after fork" do 194 | write ".spin.rb", <<-RUBY 195 | $calls = [] 196 | [:before_fork, :after_fork].each do |hook| 197 | Spin.hook(hook) { $calls << hook } 198 | end 199 | RUBY 200 | served, pushed = serve_and_push("--time", "test/calls_test.rb") 201 | served[/>>.*<>[:real_preload, :before_fork, :after_fork]<<" 202 | end 203 | end 204 | end 205 | 206 | private 207 | 208 | def root 209 | File.expand_path '../..', __FILE__ 210 | end 211 | 212 | def spin(command, options={}) 213 | command = spin_command(command) 214 | result = `#{command}` 215 | raise "FAILED #{command}\n#{result}" if $?.success? == !!options[:fail] 216 | result 217 | end 218 | 219 | def spin_command(command) 220 | "ruby -I #{root}/lib #{root}/bin/spin #{command} 2>&1" 221 | end 222 | 223 | def record_serve(output, command) 224 | IO.popen(spin_command("serve #{command}")) do |pipe| 225 | while str = pipe.readpartial(100) 226 | output << str 227 | end rescue EOFError 228 | end 229 | end 230 | 231 | def write(file, content) 232 | ensure_folder File.dirname(file) 233 | File.open(file, 'w'){|f| f.write content } 234 | end 235 | 236 | def read(file) 237 | File.read file 238 | end 239 | 240 | def delete(file) 241 | `rm #{file}` 242 | end 243 | 244 | def ensure_folder(folder) 245 | `mkdir -p #{folder}` unless File.exist?(folder) 246 | end 247 | 248 | def serve_and_push(serve_command, push_commands) 249 | serve_output = "" 250 | t1 = Thread.new { record_serve(serve_output, serve_command) } 251 | sleep 0.1 252 | push_output = [*push_commands].map{ |cmd| spin("push #{cmd}") } 253 | sleep 0.2 254 | t1.kill 255 | [serve_output, push_output] 256 | end 257 | 258 | def kill_all_threads 259 | Thread.list.each { |thread| thread.exit unless thread == Thread.current } 260 | end 261 | 262 | def kill_all_children 263 | children = child_pids 264 | `kill -9 #{children.join(" ")}` unless children.empty? 265 | end 266 | 267 | def child_pids 268 | pid = Process.pid 269 | pipe = IO.popen("ps -ef | grep #{pid}") 270 | pipe.readlines.map do |line| 271 | parts = line.split(/\s+/) 272 | parts[2] if parts[3] == pid.to_s and parts[2] != pipe.pid.to_s 273 | end.compact 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /spin.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 2 | require "spin/version" 3 | 4 | Gem::Specification.new "spin", Spin::VERSION do |s| 5 | s.authors = ["Jesse Storimer"] 6 | s.email = ["jstorimer@gmail.com"] 7 | s.homepage = "http://jstorimer.github.com/spin" 8 | s.summary = %q{Spin preloads your Rails environment to speed up your autotest(ish) workflow.} 9 | s.description = %Q{#{s.summary} 10 | 11 | By preloading your Rails environment for testing you don't load the same code over and over and over... Spin works best for an autotest(ish) workflow.} 12 | 13 | s.executables = ['spin'] 14 | s.files = ["README.md"] + Dir["lib/**/*"] 15 | s.test_files = Dir["spec/**/*"] 16 | 17 | s.add_development_dependency "rake" 18 | s.add_development_dependency "rspec", "~> 2.13.0" 19 | end 20 | --------------------------------------------------------------------------------