causes-hydra 0.21.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/.document +5 -0
- data/.gitignore +22 -0
- data/LICENSE +20 -0
- data/README.rdoc +39 -0
- data/Rakefile +56 -0
- data/TODO +18 -0
- data/VERSION +1 -0
- data/bin/warmsnake.rb +76 -0
- data/caliper.yml +6 -0
- data/hydra-icon-64x64.png +0 -0
- data/hydra.gemspec +130 -0
- data/hydra_gray.png +0 -0
- data/lib/hydra.rb +16 -0
- data/lib/hydra/cucumber/formatter.rb +29 -0
- data/lib/hydra/hash.rb +16 -0
- data/lib/hydra/js/lint.js +5150 -0
- data/lib/hydra/listener/abstract.rb +39 -0
- data/lib/hydra/listener/minimal_output.rb +24 -0
- data/lib/hydra/listener/notifier.rb +17 -0
- data/lib/hydra/listener/progress_bar.rb +48 -0
- data/lib/hydra/listener/report_generator.rb +30 -0
- data/lib/hydra/master.rb +249 -0
- data/lib/hydra/message.rb +47 -0
- data/lib/hydra/message/master_messages.rb +19 -0
- data/lib/hydra/message/runner_messages.rb +52 -0
- data/lib/hydra/message/worker_messages.rb +52 -0
- data/lib/hydra/messaging_io.rb +46 -0
- data/lib/hydra/pipe.rb +61 -0
- data/lib/hydra/runner.rb +305 -0
- data/lib/hydra/safe_fork.rb +31 -0
- data/lib/hydra/spec/autorun_override.rb +3 -0
- data/lib/hydra/spec/hydra_formatter.rb +26 -0
- data/lib/hydra/ssh.rb +41 -0
- data/lib/hydra/stdio.rb +16 -0
- data/lib/hydra/sync.rb +99 -0
- data/lib/hydra/tasks.rb +342 -0
- data/lib/hydra/trace.rb +24 -0
- data/lib/hydra/worker.rb +150 -0
- data/test/fixtures/assert_true.rb +7 -0
- data/test/fixtures/config.yml +4 -0
- data/test/fixtures/features/step_definitions.rb +21 -0
- data/test/fixtures/features/write_alternate_file.feature +7 -0
- data/test/fixtures/features/write_file.feature +7 -0
- data/test/fixtures/hello_world.rb +3 -0
- data/test/fixtures/js_file.js +4 -0
- data/test/fixtures/json_data.json +4 -0
- data/test/fixtures/slow.rb +9 -0
- data/test/fixtures/sync_test.rb +8 -0
- data/test/fixtures/write_file.rb +10 -0
- data/test/fixtures/write_file_alternate_spec.rb +10 -0
- data/test/fixtures/write_file_spec.rb +9 -0
- data/test/fixtures/write_file_with_pending_spec.rb +11 -0
- data/test/master_test.rb +152 -0
- data/test/message_test.rb +31 -0
- data/test/pipe_test.rb +38 -0
- data/test/runner_test.rb +153 -0
- data/test/ssh_test.rb +14 -0
- data/test/sync_test.rb +113 -0
- data/test/test_helper.rb +68 -0
- data/test/worker_test.rb +60 -0
- metadata +209 -0
@@ -0,0 +1,39 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
module Listener #:nodoc:
|
3
|
+
# Abstract listener that implements all the events
|
4
|
+
# but does nothing.
|
5
|
+
class Abstract
|
6
|
+
# Create a new listener.
|
7
|
+
#
|
8
|
+
# Output: The IO object for outputting any information.
|
9
|
+
# Defaults to STDOUT, but you could pass a file in, or STDERR
|
10
|
+
def initialize(output = $stdout)
|
11
|
+
@output = output
|
12
|
+
end
|
13
|
+
|
14
|
+
# Fired when testing has started
|
15
|
+
def testing_begin(files)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Fired when testing finishes, after the workers shutdown
|
19
|
+
def testing_end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Fired after runner processes have been started
|
23
|
+
def worker_begin(worker)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Fired before shutting down the worker
|
27
|
+
def worker_end(worker)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Fired when a file is started
|
31
|
+
def file_begin(file)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Fired when a file is finished
|
35
|
+
def file_end(file, output)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
module Listener #:nodoc:
|
3
|
+
# Minimal output listener. Outputs all the files at the start
|
4
|
+
# of testing and outputs a ./F/E per file. As well as
|
5
|
+
# full error output, if any.
|
6
|
+
class MinimalOutput < Hydra::Listener::Abstract
|
7
|
+
# output a starting message
|
8
|
+
def testing_begin(files)
|
9
|
+
@output.write "Hydra Testing:\n#{files.inspect}\n"
|
10
|
+
end
|
11
|
+
|
12
|
+
# output a finished message
|
13
|
+
def testing_end
|
14
|
+
@output.write "\nHydra Completed\n"
|
15
|
+
end
|
16
|
+
|
17
|
+
# For each file, just output a . for a successful file, or the
|
18
|
+
# Failure/Error output from the tests
|
19
|
+
def file_end(file, output)
|
20
|
+
@output.write output
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
module Listener #:nodoc:
|
3
|
+
# Sends a command to Notifier when the testing has finished
|
4
|
+
# http://manpages.ubuntu.com/manpages/gutsy/man1/notify-send.1.html
|
5
|
+
class Notifier < Hydra::Listener::Abstract
|
6
|
+
# output a finished notification
|
7
|
+
def testing_end
|
8
|
+
icon_path = File.join(
|
9
|
+
File.dirname(__FILE__), '..', '..', '..',
|
10
|
+
'hydra-icon-64x64.png'
|
11
|
+
)
|
12
|
+
`notify-send -i #{icon_path} "Hydra" "Testing Completed"`
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
module Listener #:nodoc:
|
3
|
+
# Output a progress bar as files are completed
|
4
|
+
class ProgressBar < Hydra::Listener::Abstract
|
5
|
+
# Store the total number of files
|
6
|
+
def testing_begin(files)
|
7
|
+
@total_files = files.size
|
8
|
+
@files_completed = 0
|
9
|
+
@test_output = ""
|
10
|
+
@errors = false
|
11
|
+
render_progress_bar
|
12
|
+
end
|
13
|
+
|
14
|
+
# Increment completed files count and update bar
|
15
|
+
def file_end(file, output)
|
16
|
+
unless output == '.'
|
17
|
+
@output.write "\r#{' '*60}\r#{output}\n"
|
18
|
+
@errors = true
|
19
|
+
end
|
20
|
+
@files_completed += 1
|
21
|
+
render_progress_bar
|
22
|
+
end
|
23
|
+
|
24
|
+
# Break the line
|
25
|
+
def testing_end
|
26
|
+
render_progress_bar
|
27
|
+
@output.write "\n"
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def render_progress_bar
|
33
|
+
width = 30
|
34
|
+
complete = ((@files_completed.to_f / @total_files.to_f) * width).to_i
|
35
|
+
@output.write "\r" # move to beginning
|
36
|
+
@output.write 'Hydra Testing ['
|
37
|
+
@output.write @errors ? "\033[0;31m" : "\033[0;32m"
|
38
|
+
complete.times{@output.write '#'}
|
39
|
+
@output.write '>'
|
40
|
+
(width-complete).times{@output.write ' '}
|
41
|
+
@output.write "\033[0m"
|
42
|
+
@output.write "] #{@files_completed}/#{@total_files}"
|
43
|
+
@output.flush
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
module Listener #:nodoc:
|
3
|
+
# Output a textual report at the end of testing
|
4
|
+
class ReportGenerator < Hydra::Listener::Abstract
|
5
|
+
# Initialize a new report
|
6
|
+
def testing_begin(files)
|
7
|
+
@report = { }
|
8
|
+
end
|
9
|
+
|
10
|
+
# Log the start time of a file
|
11
|
+
def file_begin(file)
|
12
|
+
@report[file] ||= { }
|
13
|
+
@report[file]['start'] = Time.now.to_f
|
14
|
+
end
|
15
|
+
|
16
|
+
# Log the end time of a file and compute the file's testing
|
17
|
+
# duration
|
18
|
+
def file_end(file, output)
|
19
|
+
@report[file]['end'] = Time.now.to_f
|
20
|
+
@report[file]['duration'] = @report[file]['end'] - @report[file]['start']
|
21
|
+
end
|
22
|
+
|
23
|
+
# output the report
|
24
|
+
def testing_end
|
25
|
+
YAML.dump(@report, @output)
|
26
|
+
@output.close
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/hydra/master.rb
ADDED
@@ -0,0 +1,249 @@
|
|
1
|
+
require 'hydra/hash'
|
2
|
+
require 'open3'
|
3
|
+
require 'tmpdir'
|
4
|
+
require 'erb'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
module Hydra #:nodoc:
|
8
|
+
# Hydra class responsible for delegate work down to workers.
|
9
|
+
#
|
10
|
+
# The Master is run once for any given testing session.
|
11
|
+
class YmlLoadError < StandardError; end
|
12
|
+
|
13
|
+
class Master
|
14
|
+
include Hydra::Messages::Master
|
15
|
+
include Open3
|
16
|
+
traceable('MASTER')
|
17
|
+
|
18
|
+
# Create a new Master
|
19
|
+
#
|
20
|
+
# Options:
|
21
|
+
# * :files
|
22
|
+
# * An array of test files to be run. These should be relative paths from
|
23
|
+
# the root of the project, since they may be run on different machines
|
24
|
+
# which may have different paths.
|
25
|
+
# * :workers
|
26
|
+
# * An array of hashes. Each hash should be the configuration options
|
27
|
+
# for a worker.
|
28
|
+
# * :listeners
|
29
|
+
# * An array of Hydra::Listener objects. See Hydra::Listener::MinimalOutput for an
|
30
|
+
# example listener
|
31
|
+
# * :verbose
|
32
|
+
# * Set to true to see lots of Hydra output (for debugging)
|
33
|
+
# * :autosort
|
34
|
+
# * Set to false to disable automatic sorting by historical run-time per file
|
35
|
+
def initialize(opts = { })
|
36
|
+
opts.stringify_keys!
|
37
|
+
config_file = opts.delete('config') { nil }
|
38
|
+
if config_file
|
39
|
+
|
40
|
+
begin
|
41
|
+
config_erb = ERB.new(IO.read(config_file)).result(binding)
|
42
|
+
rescue Exception => e
|
43
|
+
raise(YmlLoadError,"config file was found, but could not be parsed with ERB.\n#{$!.inspect}")
|
44
|
+
end
|
45
|
+
|
46
|
+
begin
|
47
|
+
config_yml = YAML::load(config_erb)
|
48
|
+
rescue StandardError => e
|
49
|
+
raise(YmlLoadError,"config file was found, but could not be parsed.\n#{$!.inspect}")
|
50
|
+
end
|
51
|
+
|
52
|
+
opts.merge!(config_yml.stringify_keys!)
|
53
|
+
end
|
54
|
+
@files = Array(opts.fetch('files') { nil })
|
55
|
+
raise "No files, nothing to do" if @files.empty?
|
56
|
+
@incomplete_files = @files.dup
|
57
|
+
@workers = []
|
58
|
+
@listeners = []
|
59
|
+
@stats = Hash.new(0)
|
60
|
+
@event_listeners = Array(opts.fetch('listeners') { nil } )
|
61
|
+
@event_listeners.select{|l| l.is_a? String}.each do |l|
|
62
|
+
@event_listeners.delete_at(@event_listeners.index(l))
|
63
|
+
listener = eval(l)
|
64
|
+
@event_listeners << listener if listener.is_a?(Hydra::Listener::Abstract)
|
65
|
+
end
|
66
|
+
@verbose = opts.fetch('verbose') { false }
|
67
|
+
@autosort = opts.fetch('autosort') { true }
|
68
|
+
@sync = opts.fetch('sync') { nil }
|
69
|
+
@environment = opts.fetch('environment') { 'test' }
|
70
|
+
|
71
|
+
if @autosort
|
72
|
+
sort_files_from_report
|
73
|
+
@event_listeners << Hydra::Listener::ReportGenerator.new(File.new(heuristic_file, 'w'))
|
74
|
+
end
|
75
|
+
|
76
|
+
# default is one worker that is configured to use a pipe with one runner
|
77
|
+
worker_cfg = opts.fetch('workers') { [ { 'type' => 'local', 'runners' => 1} ] }
|
78
|
+
|
79
|
+
trace "Initialized"
|
80
|
+
trace " Files: (#{@files.inspect})"
|
81
|
+
trace " Workers: (#{worker_cfg.inspect})"
|
82
|
+
trace " Verbose: (#{@verbose.inspect})"
|
83
|
+
|
84
|
+
@event_listeners.each{|l| l.testing_begin(@files) }
|
85
|
+
|
86
|
+
boot_workers worker_cfg
|
87
|
+
process_messages
|
88
|
+
|
89
|
+
report_fields = [:tests, :assertions, :errors, :failures]
|
90
|
+
report = report_fields.map{|field| "#{field}: #{@stats[field]}"}.join(', ')
|
91
|
+
puts report
|
92
|
+
end
|
93
|
+
|
94
|
+
# Message handling
|
95
|
+
def worker_begin(worker)
|
96
|
+
@event_listeners.each {|l| l.worker_begin(worker) }
|
97
|
+
end
|
98
|
+
|
99
|
+
# Send a file down to a worker.
|
100
|
+
def send_file(worker)
|
101
|
+
f = @files.shift
|
102
|
+
if f
|
103
|
+
trace "Sending #{f.inspect}"
|
104
|
+
@event_listeners.each{|l| l.file_begin(f) }
|
105
|
+
worker[:io].write(RunFile.new(:file => f))
|
106
|
+
else
|
107
|
+
trace "No more files to send"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Process the results coming back from the worker.
|
112
|
+
def process_results(worker, message)
|
113
|
+
if message.stats
|
114
|
+
message.stats.each do |key, value|
|
115
|
+
@stats[key] += value
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
if message.output =~ /ActiveRecord::StatementInvalid(.*)[Dd]eadlock/ or
|
120
|
+
message.output =~ /PGError: ERROR(.*)[Dd]eadlock/ or
|
121
|
+
message.output =~ /Mysql::Error: SAVEPOINT(.*)does not exist: ROLLBACK/ or
|
122
|
+
message.output =~ /Mysql::Error: Deadlock found/
|
123
|
+
trace "Deadlock detected running [#{message.file}]. Will retry at the end"
|
124
|
+
@files.push(message.file)
|
125
|
+
send_file(worker)
|
126
|
+
else
|
127
|
+
@incomplete_files.delete_at(@incomplete_files.index(message.file))
|
128
|
+
trace "#{@incomplete_files.size} Files Remaining"
|
129
|
+
@event_listeners.each{|l| l.file_end(message.file, message.output) }
|
130
|
+
if @incomplete_files.empty?
|
131
|
+
@workers.each do |worker|
|
132
|
+
@event_listeners.each{|l| l.worker_end(worker) }
|
133
|
+
end
|
134
|
+
|
135
|
+
shutdown_all_workers
|
136
|
+
else
|
137
|
+
send_file(worker)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# A text report of the time it took to run each file
|
143
|
+
attr_reader :report_text
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
def boot_workers(workers)
|
148
|
+
trace "Booting #{workers.size} workers"
|
149
|
+
workers.each do |worker|
|
150
|
+
worker.stringify_keys!
|
151
|
+
trace "worker opts #{worker.inspect}"
|
152
|
+
type = worker.fetch('type') { 'local' }
|
153
|
+
if type.to_s == 'local'
|
154
|
+
boot_local_worker(worker)
|
155
|
+
elsif type.to_s == 'ssh'
|
156
|
+
@workers << worker # will boot later, during the listening phase
|
157
|
+
else
|
158
|
+
raise "Worker type not recognized: (#{type.to_s})"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def boot_local_worker(worker)
|
164
|
+
runners = worker.fetch('runners') { raise "You must specify the number of runners" }
|
165
|
+
trace "Booting local worker"
|
166
|
+
pipe = Hydra::Pipe.new
|
167
|
+
child = SafeFork.fork do
|
168
|
+
pipe.identify_as_child
|
169
|
+
Hydra::Worker.new(:io => pipe, :runners => runners, :verbose => @verbose)
|
170
|
+
end
|
171
|
+
|
172
|
+
pipe.identify_as_parent
|
173
|
+
@workers << { :pid => child, :io => pipe, :idle => false, :type => :local }
|
174
|
+
end
|
175
|
+
|
176
|
+
def boot_ssh_worker(worker)
|
177
|
+
sync = Sync.new(worker, @sync, @verbose)
|
178
|
+
|
179
|
+
runners = worker.fetch('runners') { raise "You must specify the number of runners" }
|
180
|
+
command = worker.fetch('command') {
|
181
|
+
"RAILS_ENV=#{@environment} ruby -e \"require 'rubygems'; require 'hydra'; Hydra::Worker.new(:io => Hydra::Stdio.new, :runners => #{runners}, :verbose => #{@verbose});\""
|
182
|
+
}
|
183
|
+
|
184
|
+
trace "Booting SSH worker"
|
185
|
+
ssh = Hydra::SSH.new("#{sync.ssh_opts} #{sync.connect}", sync.remote_dir, command)
|
186
|
+
return { :io => ssh, :idle => false, :type => :ssh, :connect => sync.connect }
|
187
|
+
end
|
188
|
+
|
189
|
+
def shutdown_all_workers
|
190
|
+
trace "Shutting down all workers"
|
191
|
+
@workers.each do |worker|
|
192
|
+
worker[:io].write(Shutdown.new) if worker[:io]
|
193
|
+
worker[:io].close if worker[:io]
|
194
|
+
end
|
195
|
+
@listeners.each{|t| t.exit}
|
196
|
+
end
|
197
|
+
|
198
|
+
def process_messages
|
199
|
+
Thread.abort_on_exception = true
|
200
|
+
|
201
|
+
trace "Processing Messages"
|
202
|
+
trace "Workers: #{@workers.inspect}"
|
203
|
+
@workers.each do |worker|
|
204
|
+
@listeners << Thread.new do
|
205
|
+
trace "Listening to #{worker.inspect}"
|
206
|
+
if worker.fetch('type') { 'local' }.to_s == 'ssh'
|
207
|
+
worker = boot_ssh_worker(worker)
|
208
|
+
@workers << worker
|
209
|
+
end
|
210
|
+
while true
|
211
|
+
begin
|
212
|
+
message = worker[:io].gets
|
213
|
+
trace "got message: #{message}"
|
214
|
+
# if it exists and its for me.
|
215
|
+
# SSH gives us back echoes, so we need to ignore our own messages
|
216
|
+
if message and !message.class.to_s.index("Worker").nil?
|
217
|
+
message.handle(self, worker)
|
218
|
+
end
|
219
|
+
rescue IOError
|
220
|
+
trace "lost Worker [#{worker.inspect}]"
|
221
|
+
Thread.exit
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
@listeners.each{|l| l.join}
|
228
|
+
@event_listeners.each{|l| l.testing_end}
|
229
|
+
end
|
230
|
+
|
231
|
+
def sort_files_from_report
|
232
|
+
if File.exists? heuristic_file
|
233
|
+
report = YAML.load_file(heuristic_file)
|
234
|
+
return unless report
|
235
|
+
sorted_files = report.sort{ |a,b|
|
236
|
+
b[1]['duration'] <=> a[1]['duration']
|
237
|
+
}.collect{|tuple| tuple[0]}
|
238
|
+
|
239
|
+
sorted_files.each do |f|
|
240
|
+
@files.push(@files.delete_at(@files.index(f))) if @files.index(f)
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def heuristic_file
|
246
|
+
@heuristic_file ||= File.join(Dir.tmpdir, 'hydra_heuristics.yml')
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Hydra #:nodoc:
|
2
|
+
# Base message object. Used to pass messages with parameters around
|
3
|
+
# via IO objects.
|
4
|
+
# class MyMessage < Hydra::Message
|
5
|
+
# attr_accessor :my_var
|
6
|
+
# def serialize
|
7
|
+
# super(:my_var => @my_var)
|
8
|
+
# end
|
9
|
+
# end
|
10
|
+
# m = MyMessage.new(:my_var => 'my value')
|
11
|
+
# m.my_var
|
12
|
+
# => "my value"
|
13
|
+
# m.serialize
|
14
|
+
# => "{:class=>TestMessage::MyMessage, :my_var=>\"my value\"}"
|
15
|
+
# Hydra::Message.build(eval(@m.serialize)).my_var
|
16
|
+
# => "my value"
|
17
|
+
class Message
|
18
|
+
# Create a new message. Opts is a hash where the keys
|
19
|
+
# are attributes of the message and the values are
|
20
|
+
# set to the attribute.
|
21
|
+
def initialize(opts = {})
|
22
|
+
opts.delete :class
|
23
|
+
opts.each do |variable,value|
|
24
|
+
self.send("#{variable}=",value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Build a message from a hash. The hash must contain
|
29
|
+
# the :class symbol, which is the class of the message
|
30
|
+
# that it will build to.
|
31
|
+
def self.build(hash)
|
32
|
+
hash.delete(:class).new(hash)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Serialize the message for output on an IO channel.
|
36
|
+
# This is really just a string representation of a hash
|
37
|
+
# with no newlines. It adds in the class automatically
|
38
|
+
def serialize(opts = {})
|
39
|
+
opts.merge({:class => self.class}).inspect
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
require 'hydra/message/runner_messages'
|
45
|
+
require 'hydra/message/worker_messages'
|
46
|
+
require 'hydra/message/master_messages'
|
47
|
+
|