test-loop 6.0.0 → 7.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. data/README.md +41 -27
  2. data/bin/test-loop +169 -110
  3. metadata +3 -3
data/README.md CHANGED
@@ -39,11 +39,14 @@ Features
39
39
 
40
40
  * Executes test files in parallel, making full use of multiple processors.
41
41
 
42
+ * Logs the output from your tests into separate files: one log per test file.
43
+ The path to a log file is simply the path of its test file plus ".log".
44
+
42
45
  * Generally I/O bound, so you can have it always running without CPU slowdown.
43
46
 
44
47
  * Configurable through a `.test-loop` file in your current working directory.
45
48
 
46
- * Implemented in less than 100 lines (SLOC) of pure Ruby code! :-)
49
+ * Implemented in less than 150 lines (SLOC) of pure Ruby code! :-)
47
50
 
48
51
 
49
52
  Installation
@@ -82,23 +85,25 @@ Operation
82
85
 
83
86
  * Press Control-C or send the SIGINT signal to quit the test loop.
84
87
 
88
+ * Send the SIGUSR1 signal to terminate workers and their subprocesses.
89
+
85
90
 
86
91
  Configuration
87
92
  -------------
88
93
 
89
94
  test-loop looks for a configuration file named `.test-loop` in the current
90
- working directory. This configuration file is a normal Ruby script, in which
91
- you can query and modify the `$test_loop_config` OpenStruct object as follows:
95
+ working directory. This configuration file is a Ruby script in which you
96
+ can query and modify the `Test::Loop::Config` OpenStruct object as follows:
92
97
 
93
- * `$test_loop_config.overhead_file_globs` is an array of file globbing
98
+ * `Test::Loop::Config.overhead_file_globs` is an array of file globbing
94
99
  patterns that describe a set of Ruby scripts that are loaded into the main
95
100
  Ruby process as overhead.
96
101
 
97
- * `$test_loop_config.reabsorb_file_globs` is an array of file globbing
102
+ * `Test::Loop::Config.reabsorb_file_globs` is an array of file globbing
98
103
  patterns that describe a set of files which cause the overhead to be
99
104
  reabsorbed whenever they change.
100
105
 
101
- * `$test_loop_config.test_file_matchers` is a hash that maps a file globbing
106
+ * `Test::Loop::Config.test_file_matchers` is a hash that maps a file globbing
102
107
  pattern describing a set of source files to a lambda function yielding a
103
108
  file globbing pattern describing a set of test files that need to be run.
104
109
  In other words, whenever the source files (the hash key; left-hand side of
@@ -113,51 +118,60 @@ you can query and modify the `$test_loop_config` OpenStruct object as follows:
113
118
 
114
119
  Then you would add the following to your configuration file:
115
120
 
116
- $test_loop_config.test_file_matchers['{lib,app}/**/*.rb'] = lambda do |path|
121
+ Test::Loop::Config.test_file_matchers['{lib,app}/**/*.rb'] = lambda do |path|
117
122
  extn = File.extname(path)
118
123
  name = File.basename(path, extn)
119
124
  "{test,spec}/**/#{name.reverse}#{extn}" # <== notice the reverse()
120
125
  end
121
126
 
122
- * `$test_loop_config.test_name_parser` is a lambda function that is passed a
127
+ * `Test::Loop::Config.test_name_parser` is a lambda function that is passed a
123
128
  line of source code to determine whether that line can be considered as a
124
129
  test definition, in which case it must return the name of the test being
125
130
  defined.
126
131
 
127
- * `$test_loop_config.before_each_test` is a lambda function that is executed
132
+ * `Test::Loop::Config.before_each_test` is a lambda function that is executed
128
133
  inside the worker process before loading the test file. It is passed (1)
129
- the path to the test file and (2) the names of tests (which were identified
130
- by `$test_loop_config.test_name_parser`) inside the test file that have
131
- changed since the last time the test file was run.
134
+ the path to the test file, (2) the path to the log file containing the live
135
+ output of running the test file, and (3) an array containing the names of
136
+ tests (which were identified by `Test::Loop::Config.test_name_parser`)
137
+ inside the test file that have changed since the last run of the test file.
132
138
 
133
139
  These test names should be passed down to your chosen testing library,
134
140
  instructing it to skip all other tests except those passed down to it. This
135
141
  accelerates your test-driven development cycle and improves productivity!
136
142
 
137
- * `$test_loop_config.after_all_tests` is a lambda function that is executed
138
- inside the master process after all tests have finished running. It is
139
- passed (1) a list of passing test files along with the exit statuses of the
140
- worker processes that ran them, (2) a list of failing test files along with
141
- the exit statuses of the worker processes that ran them, (3) the time when
142
- test execution began, and (4) how many seconds it took for the overall test
143
- execution to complete.
143
+ * `Test::Loop::Config.after_each_test` is a lambda function that is executed
144
+ inside the master process after a test has finished running. It is passed
145
+ (1) the path to the test file, (2) the path to the log file containing the
146
+ output of running the test file, (3) a `Process::Status` object describing
147
+ the exit status of the worker process that ran the test file, (4) the time
148
+ when test execution began, and (5) how many seconds it took for the overall
149
+ test execution to complete.
144
150
 
145
- For example, to display a summary of the test execution results as an OSD
146
- notification, add the following to your configuration file:
151
+ For example, to display a summary of the test execution results as an
152
+ on-screen-display notification while also displaying the log file if the
153
+ test failed, add the following to your configuration file:
147
154
 
148
- $test_loop_config.after_all_tests = lambda do |passes, fails, started_at, elapsed_time|
149
- success = fails.empty?
155
+ Test::Loop::Config.after_each_test = lambda do |test_file, log_file, run_status, started_at, elapsed_time|
156
+ success = run_status.success?
150
157
 
151
- title = started_at.strftime("#{success ? 'PASS' : 'FAIL'} at %X on %x")
158
+ title = '%s at %s in %0.1fs' %
159
+ [success ? 'PASS' : 'FAIL', started_at.strftime('%X'), elapsed_time]
152
160
 
153
- message = '%d ran, %d passed, %d failed in %0.1f seconds' %
154
- [passes.length + fails.length, passes.length, fails.length, elapsed_time]
161
+ message = test_file
155
162
 
156
- Thread.new do # launch in background
163
+ Thread.new do # run in background
157
164
  system 'notify-send', '-i', "dialog-#{success ? 'information' : 'error'}", title, message or
158
165
  system 'growlnotify', '-a', 'Xcode', '-m', message, title or
159
166
  system 'xmessage', '-timeout', '5', '-title', title, message or
160
167
  puts title, message
168
+
169
+ unless success # show failure log
170
+ system 'xdg-open', log_file or
171
+ system 'open', log_file or
172
+ system 'xmessage', '-title', log_file, '-file', log_file or
173
+ puts log_file, File.read(log_file)
174
+ end
161
175
  end
162
176
  end
163
177
 
data/bin/test-loop CHANGED
@@ -22,151 +22,210 @@
22
22
  # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
23
23
  #
24
24
 
25
- process_invocation_vector = [$0, *ARGV].map! {|s| s.dup }
26
-
27
25
  require 'ostruct'
28
26
  require 'diff/lcs'
29
27
 
30
- begin
31
- notify = lambda {|message| puts "test-loop: #{message}" }
32
-
33
- # supply default configuration
34
- config = $test_loop_config = OpenStruct.new
35
-
36
- config.overhead_file_globs = ['{test,spec}/{test,spec}_helper.rb']
28
+ module Test
29
+ module Loop
30
+ CONFIG_FILE = File.join(Dir.pwd, '.test-loop')
31
+
32
+ Config = OpenStruct.new(
33
+ :overhead_file_globs => ['{test,spec}/{test,spec}_helper.rb'],
34
+
35
+ :reabsorb_file_globs => ['{test,spec}/{test,spec}_helper.rb',
36
+ 'config/*.{rb,yml}', 'Gemfile.lock',
37
+ File.basename(CONFIG_FILE)],
38
+
39
+ :test_file_matchers => {
40
+ # source files that correspond to test files
41
+ '{lib,app}/**/*.rb' => lambda do |path|
42
+ extn = File.extname(path)
43
+ name = File.basename(path, extn)
44
+ "{test,spec}/**/#{name}_{test,spec}#{extn}"
45
+ end,
46
+
47
+ # the actual test files themselves
48
+ '{test,spec}/**/*_{test,spec}.rb' => lambda {|path| path }
49
+ },
50
+
51
+ :test_name_parser => lambda do |line|
52
+ case line
53
+ when /^\s*def\s+test_(\w+)/ then $1
54
+ when /^\s*(test|context|should|describe|it)\b.+?(['"])(.*?)\2/ then $3
55
+ end
56
+ end,
57
+
58
+ :before_each_test => lambda do |test_file, log_file, test_names|
59
+ unless test_names.empty?
60
+ test_name_pattern = test_names.map do |name|
61
+ # sanitize string interpolation and non-method-name characters
62
+ name.gsub(/\#\{.*?\}/, ' ').strip.gsub(/\W+/, '.*')
63
+ end.join('|')
64
+
65
+ case File.basename(test_file)
66
+ when /(\b|_)test(\b|_)/ # Test::Unit
67
+ ARGV.push '--name', "/#{test_name_pattern}/"
68
+ when /(\b|_)spec(\b|_)/ # RSpec
69
+ ARGV.push '--example', test_name_pattern
70
+ end
71
+ end
72
+ end,
73
+
74
+ :after_each_test =>
75
+ lambda {|test_file, log_file, run_status, started_at, elapsed_time|}
76
+ )
77
+
78
+ def self.run
79
+ register_signals
80
+ load_user_config
81
+ absorb_overhead
82
+ run_test_loop
83
+ rescue Exception => error
84
+ STDERR.puts error.inspect, error.backtrace
85
+ sleep 1
86
+ reload_master_process
87
+ end
37
88
 
38
- config.reabsorb_file_globs = config.overhead_file_globs +
39
- ['config/*.{rb,yml}', 'Gemfile.lock', '.test-loop']
89
+ class << self; private
90
+ SCRIPT_NAME = File.basename($0)
91
+ EXEC_VECTOR = [$0, *ARGV].map {|s| s.dup.freeze }.freeze
40
92
 
41
- config.test_file_matchers = {
42
- # source files that correspond to test files
43
- '{lib,app}/**/*.rb' => lambda do |path|
44
- extn = File.extname(path)
45
- name = File.basename(path, extn)
46
- "{test,spec}/**/#{name}_{test,spec}#{extn}"
47
- end,
93
+ def notify message
94
+ # using print() because puts() is not an atomic operation
95
+ print "#{SCRIPT_NAME}: #{message}\n"
96
+ end
48
97
 
49
- # the actual test files themselves
50
- '{test,spec}/**/*_{test,spec}.rb' => lambda {|path| path }
51
- }
98
+ def register_signals
99
+ trap(:INT) { destroy_process_group }
100
+ master_pid = $$ # kill only the workers, not the master
101
+ trap(:USR1) { destroy_process_group unless $$ == master_pid }
102
+ trap(:QUIT) { reload_master_process }
103
+ trap(:TSTP) {} # ignore until ready for testing in run_test_loop()
104
+ end
52
105
 
53
- config.test_name_parser = lambda do |line|
54
- case line
55
- when /^\s*def\s+test_(\w+)/ then $1
56
- when /^\s*(test|context|should|describe|it)\b.+?(['"])(.*?)\2/ then $3
57
- end
58
- end
106
+ def destroy_process_group
107
+ Process.kill :KILL, -$$
108
+ end
59
109
 
60
- config.before_each_test = lambda do |test_file, test_names|
61
- unless test_names.empty?
62
- test_name_pattern = test_names.map do |name|
63
- # sanitize string interpolation and non-method-name characters
64
- name.gsub(/\#\{.*?\}/, ' ').strip.gsub(/\W+/, '.*')
65
- end.join('|')
66
-
67
- case File.basename(test_file)
68
- when /(\b|_)test(\b|_)/ # Test::Unit
69
- ARGV.push '--name', "/#{test_name_pattern}/"
70
- when /(\b|_)spec(\b|_)/ # RSpec
71
- ARGV.push '--example', test_name_pattern
110
+ def reload_master_process
111
+ notify 'Restarting loop...'
112
+ Process.kill :USR1, -$$
113
+ exec(*EXEC_VECTOR)
72
114
  end
73
- end
74
- end
75
115
 
76
- config.after_all_tests = lambda {|passes, fails, started_at, elapsed_time|}
116
+ def load_user_config
117
+ if File.exist? CONFIG_FILE
118
+ notify 'Loading configuration...'
119
+ load CONFIG_FILE
120
+ end
121
+ end
77
122
 
78
- # load user's configuration overrides
79
- if File.exist? config_file = File.join(Dir.pwd, '.test-loop')
80
- notify.call 'Loading configuration...'
81
- load config_file
82
- end
123
+ def absorb_overhead
124
+ notify 'Absorbing overhead...'
125
+ $LOAD_PATH.unshift 'lib', 'test', 'spec'
126
+ Dir[*Config.overhead_file_globs].each do |file|
127
+ require File.basename(file, File.extname(file))
128
+ end
129
+ end
83
130
 
84
- # absorb test execution overhead into master process
85
- $LOAD_PATH.unshift 'lib', 'test', 'spec'
131
+ def run_test_loop
132
+ @running_files = []
133
+ @running_files_lock = Mutex.new
134
+
135
+ @lines_by_file = {} # path => readlines
136
+ @last_ran_at = @started_at = Time.now
137
+ trap(:TSTP) { @last_ran_at = Time.at(0); @lines_by_file.clear }
138
+
139
+ notify 'Ready for testing!'
140
+ loop do
141
+ # figure out what test files need to be run
142
+ test_files = Config.test_file_matchers.map \
143
+ do |source_glob, test_matcher|
144
+ Dir[source_glob].select {|file| File.mtime(file) > @last_ran_at }.
145
+ map {|path| Dir[test_matcher.call path] }
146
+ end.flatten.uniq
147
+
148
+ test_files = @running_files_lock.
149
+ synchronize { test_files - @running_files }
150
+
151
+ # fork worker processes to run the test files in parallel
152
+ @last_ran_at = Time.now
153
+ test_files.each {|f| run_test_file f }
154
+
155
+ # reabsorb test execution overhead as necessary
156
+ if Dir[*Config.reabsorb_file_globs].
157
+ any? {|file| File.mtime(file) > @started_at }
158
+ then
159
+ Process.kill :QUIT, $$
160
+ end
161
+
162
+ sleep 1
163
+ end
164
+ end
86
165
 
87
- notify.call 'Absorbing overhead...'
88
- Dir[*config.overhead_file_globs].each do |file|
89
- require File.basename(file, File.extname(file))
90
- end
166
+ def run_test_file test_file
167
+ @running_files_lock.synchronize { @running_files.push test_file }
168
+ log_file = test_file + '.log'
91
169
 
92
- # continuously watch for and test changed code
93
- test_file_cache = {} # path => readlines
94
- started_at = last_ran_at = Time.now
95
- trap(:QUIT) { started_at = Time.at(0) }
96
- trap(:TSTP) { last_ran_at = Time.at(0); test_file_cache.clear }
97
-
98
- notify.call 'Ready for testing!'
99
- loop do
100
- # figure out what test files need to be run
101
- test_files = config.test_file_matchers.map do |source_glob, test_matcher|
102
- Dir[source_glob].select {|file| File.mtime(file) > last_ran_at }.
103
- map {|path| Dir[test_matcher.call path] }
104
- end.flatten.uniq
105
-
106
- # fork worker processes to run the test files in parallel
107
- unless test_files.empty?
108
- notify.call 'Running tests...'
109
- last_ran_at = Time.now
110
-
111
- test_files.each do |test_file|
112
170
  # cache the contents of the test file for diffing below
113
171
  new_lines = File.readlines(test_file)
114
- old_lines = test_file_cache[test_file] || new_lines
115
- test_file_cache[test_file] = new_lines
172
+ old_lines = @lines_by_file[test_file] || new_lines
173
+ @lines_by_file[test_file] = new_lines
174
+
175
+ worker_pid = fork do
176
+ # capture test output in log file because tests are run in parallel
177
+ # which makes it difficult to understand interleaved output thereof
178
+ $stdout.reopen(log_file, 'w').sync = true
179
+ $stderr.reopen($stdout)
116
180
 
117
- fork do
118
181
  # determine which test blocks have changed inside the test file
119
- test_names = Diff::LCS.diff(old_lines, new_lines).flatten.map do |change|
182
+ test_names = Diff::LCS.diff(old_lines, new_lines).flatten.map \
183
+ do |change|
120
184
  catch :found do
121
185
  # search backwards from the line that changed up to
122
186
  # the first line in the file for test definitions
123
187
  change.position.downto(0) do |i|
124
- if test_name = config.test_name_parser.call(new_lines[i])
188
+ if test_name = Config.test_name_parser.call(new_lines[i])
125
189
  throw :found, test_name
126
190
  end
127
191
  end; nil # prevent unsuccessful search from returning an integer
128
192
  end
129
193
  end.compact.uniq
130
194
 
131
- config.before_each_test.call test_file, test_names
195
+ # tell the testing framework to run only the changed test blocks
196
+ Config.before_each_test.call test_file, log_file, test_names
132
197
 
133
- load test_file
134
-
135
- # at this point, the at_exit() hook of the testing framework used by
136
- # the user's test suite will take care of running all tests defined
137
- # by the test file loaded above and will also reflect any failures
138
- # in the worker's exit status
198
+ # after loading the user's test file, the at_exit() hook of the
199
+ # user's testing framework will take care of running the tests and
200
+ # reflecting any failures in the worker process' exit status
201
+ load $0 = test_file # set $0 because Test::Unit outputs it
139
202
  end
140
- end
141
203
 
142
- # wait for worker processes to finish and report results
143
- test_runs = Process.waitall.map {|pid, status| status }
144
- elapsed_time = Time.now - last_ran_at
204
+ # monitor and report on the worker's progress
205
+ Thread.new do
206
+ report = lambda do |state|
207
+ notify [state, worker_pid, test_file].join("\t")
208
+ end
209
+ report.call :RUN
145
210
 
146
- passes, fails = test_files.zip(test_runs).each do |file, run|
147
- notify.call "#{run.success? ? 'PASS' : 'FAIL'} #{file}"
148
- end.partition {|file, run| run.success? }
211
+ # wait for worker to finish
212
+ begin
213
+ Process.waitpid worker_pid
214
+ rescue Errno::ECHILD
215
+ # worker finished and the OS has forgotten about it already
216
+ end
217
+ elapsed_time = Time.now - @last_ran_at
149
218
 
150
- notify.call '%d ran, %d passed, %d failed in %0.1f seconds' %
151
- [test_runs.length, passes.length, fails.length, elapsed_time]
219
+ report.call $?.success? && :PASS || :FAIL
152
220
 
153
- config.after_all_tests.call passes, fails, last_ran_at, elapsed_time
154
- end
221
+ Config.after_each_test.call \
222
+ test_file, log_file, $?, @last_ran_at, elapsed_time
155
223
 
156
- # reabsorb test execution overhead as necessary
157
- if Dir[*config.reabsorb_file_globs].any? {|file| File.mtime(file) > started_at }
158
- notify.call 'Restarting loop...'
159
- exec(*process_invocation_vector)
224
+ @running_files_lock.synchronize { @running_files.delete test_file }
225
+ end
226
+ end
160
227
  end
161
-
162
- sleep 1
163
228
  end
164
-
165
- rescue Interrupt
166
- # user wants to quit the loop so terminate all worker processes
167
- Process.kill :SIGKILL, -$$ # negative PID propagates signal to children
168
-
169
- rescue Exception => error
170
- STDERR.puts error.inspect, error.backtrace
171
- sleep 1 and exec(*process_invocation_vector)
172
229
  end
230
+
231
+ Test::Loop.run
metadata CHANGED
@@ -3,10 +3,10 @@ name: test-loop
3
3
  version: !ruby/object:Gem::Version
4
4
  prerelease: false
5
5
  segments:
6
- - 6
6
+ - 7
7
7
  - 0
8
8
  - 0
9
- version: 6.0.0
9
+ version: 7.0.0
10
10
  platform: ruby
11
11
  authors:
12
12
  - Suraj N. Kurapati
@@ -14,7 +14,7 @@ autorequire:
14
14
  bindir: bin
15
15
  cert_chain: []
16
16
 
17
- date: 2011-02-09 00:00:00 -08:00
17
+ date: 2011-02-10 00:00:00 -08:00
18
18
  default_executable:
19
19
  dependencies:
20
20
  - !ruby/object:Gem::Dependency