test-loop 6.0.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +41 -27
- data/bin/test-loop +169 -110
- 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
|
|
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
|
|
91
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
138
|
-
inside the master process after
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the exit
|
|
142
|
-
test execution began, and (
|
|
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
|
|
146
|
-
notification
|
|
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
|
-
|
|
149
|
-
success =
|
|
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 =
|
|
158
|
+
title = '%s at %s in %0.1fs' %
|
|
159
|
+
[success ? 'PASS' : 'FAIL', started_at.strftime('%X'), elapsed_time]
|
|
152
160
|
|
|
153
|
-
message =
|
|
154
|
-
[passes.length + fails.length, passes.length, fails.length, elapsed_time]
|
|
161
|
+
message = test_file
|
|
155
162
|
|
|
156
|
-
Thread.new do #
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
89
|
+
class << self; private
|
|
90
|
+
SCRIPT_NAME = File.basename($0)
|
|
91
|
+
EXEC_VECTOR = [$0, *ARGV].map {|s| s.dup.freeze }.freeze
|
|
40
92
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
115
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
#
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
[test_runs.length, passes.length, fails.length, elapsed_time]
|
|
219
|
+
report.call $?.success? && :PASS || :FAIL
|
|
152
220
|
|
|
153
|
-
|
|
154
|
-
|
|
221
|
+
Config.after_each_test.call \
|
|
222
|
+
test_file, log_file, $?, @last_ran_at, elapsed_time
|
|
155
223
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
- 7
|
|
7
7
|
- 0
|
|
8
8
|
- 0
|
|
9
|
-
version:
|
|
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-
|
|
17
|
+
date: 2011-02-10 00:00:00 -08:00
|
|
18
18
|
default_executable:
|
|
19
19
|
dependencies:
|
|
20
20
|
- !ruby/object:Gem::Dependency
|