cukeq 0.0.1.dev
Sign up to get free protection for your applications and to get access to all the features.
- data/.autotest +1 -0
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +73 -0
- data/Rakefile +68 -0
- data/VERSION +1 -0
- data/bin/cukeq +34 -0
- data/features/cukeq.feature +15 -0
- data/features/example1.feature +1 -0
- data/features/example2.feature +1 -0
- data/features/step_definitions/cukeq_steps.rb +22 -0
- data/features/support/cukeq_helper.rb +73 -0
- data/features/support/env.rb +13 -0
- data/features/support/report_app.rb +36 -0
- data/lib/cukeq.rb +45 -0
- data/lib/cukeq/broker.rb +65 -0
- data/lib/cukeq/em/system3.rb +53 -0
- data/lib/cukeq/job_clearer.rb +19 -0
- data/lib/cukeq/master.rb +146 -0
- data/lib/cukeq/reporter.rb +22 -0
- data/lib/cukeq/runner.rb +63 -0
- data/lib/cukeq/scenario_exploder.rb +32 -0
- data/lib/cukeq/scenario_runner.rb +134 -0
- data/lib/cukeq/scm.rb +51 -0
- data/lib/cukeq/scm/git_bridge.rb +36 -0
- data/lib/cukeq/scm/shell_svn_bridge.rb +65 -0
- data/lib/cukeq/scm/svn_bridge.rb +77 -0
- data/lib/cukeq/slave.rb +114 -0
- data/lib/cukeq/webapp.rb +38 -0
- data/spec/cukeq/broker_spec.rb +78 -0
- data/spec/cukeq/cukeq_spec.rb +10 -0
- data/spec/cukeq/master_spec.rb +153 -0
- data/spec/cukeq/reporter_spec.rb +29 -0
- data/spec/cukeq/runner_spec.rb +11 -0
- data/spec/cukeq/scenario_exploder_spec.rb +17 -0
- data/spec/cukeq/scenario_runner_spec.rb +32 -0
- data/spec/cukeq/scm/git_bridge_spec.rb +52 -0
- data/spec/cukeq/scm/svn_bridge_spec.rb +5 -0
- data/spec/cukeq/scm_spec.rb +48 -0
- data/spec/cukeq/slave_spec.rb +86 -0
- data/spec/cukeq/webapp_spec.rb +37 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +12 -0
- metadata +228 -0
data/lib/cukeq/broker.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
module CukeQ
|
2
|
+
class Broker
|
3
|
+
attr_reader :user, :pass, :host, :port, :vhost
|
4
|
+
|
5
|
+
def initialize(uri, opts = {})
|
6
|
+
uri = URI.parse(uri) if uri.kind_of? String
|
7
|
+
|
8
|
+
@user = uri.user || raise(ArgumentError, "no user given")
|
9
|
+
@pass = uri.password || 'cukeq123'
|
10
|
+
@host = uri.host || 'localhost'
|
11
|
+
@port = uri.port || 5672
|
12
|
+
@vhost = uri.path || '/cukeq'
|
13
|
+
|
14
|
+
@timeout = Integer(opts[:timeout] || 20)
|
15
|
+
@queues = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
log self.class, :start, "#{@host}:#{@port}#{@vhost}"
|
20
|
+
|
21
|
+
AMQP.start(amqp_options) do
|
22
|
+
create_queues
|
23
|
+
yield if block_given?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def publish(queue_name, json)
|
28
|
+
queue_for(queue_name).publish(json)
|
29
|
+
end
|
30
|
+
|
31
|
+
def subscribe(queue_name, &blk)
|
32
|
+
queue_for(queue_name).subscribe(&blk)
|
33
|
+
end
|
34
|
+
|
35
|
+
def unsubscribe(queue_name, &blk)
|
36
|
+
queue_for(queue_name).unsubscribe(&blk)
|
37
|
+
end
|
38
|
+
|
39
|
+
def queue_for(name)
|
40
|
+
@queues[name] || raise("unknown queue: #{name.inspect}")
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def create_queues
|
46
|
+
@queues[:ping] = MQ.new.queue("cukeq.ping")
|
47
|
+
@queues[:pong] = MQ.new.queue("cukeq.pong")
|
48
|
+
|
49
|
+
@queues[:results] = MQ.new.queue("cukeq.results")
|
50
|
+
@queues[:jobs] = MQ.new.queue("cukeq.jobs")
|
51
|
+
end
|
52
|
+
|
53
|
+
def amqp_options
|
54
|
+
{
|
55
|
+
:host => @host,
|
56
|
+
:port => @port,
|
57
|
+
:user => @user,
|
58
|
+
:pass => @pass,
|
59
|
+
:vhost => @vhost,
|
60
|
+
:timeout => 20
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
end # QueueHandler
|
65
|
+
end # CukeQ
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module EventMachine
|
2
|
+
|
3
|
+
#
|
4
|
+
# http://github.com/eventmachine/eventmachine/issues#issue/15
|
5
|
+
#
|
6
|
+
|
7
|
+
def self.system3(cmd, *args, &cb)
|
8
|
+
cb ||= args.pop if args.last.is_a? Proc
|
9
|
+
init = args.pop if args.last.is_a? Proc
|
10
|
+
|
11
|
+
# merge remaining arguments into the command
|
12
|
+
cmd = ([cmd] + args.map{|a|a.to_s.dump}).join(' ')
|
13
|
+
|
14
|
+
new_stderr = $stderr.dup
|
15
|
+
|
16
|
+
rd, wr = IO::pipe
|
17
|
+
|
18
|
+
result_count = 0
|
19
|
+
|
20
|
+
err_result = nil
|
21
|
+
std_result = nil
|
22
|
+
stderr_connection = nil
|
23
|
+
|
24
|
+
err_proc = proc {|output, status|
|
25
|
+
stderr_connection = nil
|
26
|
+
err_result = output
|
27
|
+
result_count+=1
|
28
|
+
if result_count == 2
|
29
|
+
cb[std_result, err_result, status]
|
30
|
+
end
|
31
|
+
}
|
32
|
+
|
33
|
+
std_proc = proc {|output, status|
|
34
|
+
stderr_connection.close_connection if stderr_connection
|
35
|
+
rd.close
|
36
|
+
std_result = output
|
37
|
+
result_count += 1
|
38
|
+
if result_count == 2
|
39
|
+
cb[std_result, err_result, status]
|
40
|
+
end
|
41
|
+
}
|
42
|
+
|
43
|
+
$stderr.reopen(wr)
|
44
|
+
signature = EM.popen(cmd, SystemCmd, std_proc) do |c|
|
45
|
+
init[c] if init
|
46
|
+
end.signature
|
47
|
+
stderr_connection = EM.attach(rd, SystemCmd, err_proc)
|
48
|
+
$stderr.reopen(new_stderr)
|
49
|
+
wr.close
|
50
|
+
|
51
|
+
return EventMachine.get_subprocess_pid(signature)
|
52
|
+
end unless EventMachine.respond_to?(:system3)
|
53
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
|
2
|
+
module CukeQ
|
3
|
+
|
4
|
+
#
|
5
|
+
# TODO: must be a better way
|
6
|
+
#
|
7
|
+
|
8
|
+
class JobClearer < Slave
|
9
|
+
|
10
|
+
def poll
|
11
|
+
EM.next_tick {
|
12
|
+
job = @broker.queue_for(:jobs).pop
|
13
|
+
log(self.class, :ignoring, job)
|
14
|
+
poll
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
end # JobClearer
|
19
|
+
end # CukeQ
|
data/lib/cukeq/master.rb
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
module CukeQ
|
2
|
+
class Master
|
3
|
+
DEFAULT_BROKER_URI = URI.parse("amqp://cukeq-master:cukeq123@localhost:5672/cukeq")
|
4
|
+
DEFAULT_WEBAPP_URI = URI.parse("http://0.0.0.0:9292")
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def execute(argv)
|
8
|
+
configured_instance(argv).start
|
9
|
+
end
|
10
|
+
|
11
|
+
def configured_instance(argv)
|
12
|
+
opts = parse(argv)
|
13
|
+
|
14
|
+
raise ArgumentError, "must provide --scm" unless opts[:scm]
|
15
|
+
raise ArgumentError, "must provide --report_to" unless opts[:report_to]
|
16
|
+
|
17
|
+
if b = opts[:broker]
|
18
|
+
b.user ||= DEFAULT_BROKER_URI.user
|
19
|
+
b.password ||= DEFAULT_BROKER_URI.password
|
20
|
+
b.host ||= DEFAULT_BROKER_URI.host
|
21
|
+
b.port ||= DEFAULT_BROKER_URI.port
|
22
|
+
b.path = b.path.empty? ? DEFAULT_BROKER_URI.path : b.path
|
23
|
+
else
|
24
|
+
opts[:broker] = DEFAULT_BROKER_URI
|
25
|
+
end
|
26
|
+
|
27
|
+
if w = opts[:webapp]
|
28
|
+
w.host ||= DEFAULT_WEBAPP_URI.host
|
29
|
+
w.port ||= DEFAULT_WEBAPP_URI.port
|
30
|
+
else
|
31
|
+
opts[:webapp] = DEFAULT_WEBAPP_URI
|
32
|
+
end
|
33
|
+
|
34
|
+
new(
|
35
|
+
Broker.new(opts[:broker]),
|
36
|
+
WebApp.new(opts[:webapp]),
|
37
|
+
Scm.new(opts[:scm]),
|
38
|
+
Reporter.new(opts[:report_to]),
|
39
|
+
ScenarioExploder.new
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse(argv)
|
44
|
+
options = {}
|
45
|
+
|
46
|
+
argv.extend OptionParser::Arguable
|
47
|
+
argv.options do |opts|
|
48
|
+
opts.on("-b", "--broker URI (default: #{DEFAULT_BROKER_URI})") do |str|
|
49
|
+
options[:broker] = URI.parse(str)
|
50
|
+
end
|
51
|
+
|
52
|
+
opts.on("-w", "--webapp URI (default: http://localhost:9292)") do |str|
|
53
|
+
options[:webapp] = URI.parse(str)
|
54
|
+
end
|
55
|
+
|
56
|
+
opts.on("-s", "--scm SCM-URL") do |url|
|
57
|
+
options[:scm] = URI.parse(url)
|
58
|
+
end
|
59
|
+
|
60
|
+
opts.on("-r", "--report-to REPORTER-URL") do |url|
|
61
|
+
options[:report_to] = URI.parse(url)
|
62
|
+
end
|
63
|
+
end.parse!
|
64
|
+
|
65
|
+
options
|
66
|
+
end
|
67
|
+
end # class << self
|
68
|
+
|
69
|
+
attr_reader :broker, :webapp, :scm, :reporter, :exploder
|
70
|
+
|
71
|
+
def initialize(broker, webapp, scm, reporter, exploder)
|
72
|
+
@broker = broker
|
73
|
+
@webapp = webapp
|
74
|
+
@scm = scm
|
75
|
+
@reporter = reporter
|
76
|
+
@exploder = exploder
|
77
|
+
end
|
78
|
+
|
79
|
+
def start
|
80
|
+
@scm.update {
|
81
|
+
@broker.start {
|
82
|
+
subscribe
|
83
|
+
start_webapp
|
84
|
+
}
|
85
|
+
}
|
86
|
+
end
|
87
|
+
|
88
|
+
def ping(&blk)
|
89
|
+
log log_name, :ping
|
90
|
+
@broker.subscribe :pong, &blk
|
91
|
+
@broker.publish :ping, '{}'
|
92
|
+
end
|
93
|
+
|
94
|
+
#
|
95
|
+
# This is triggered by POSTs to the webapp
|
96
|
+
#
|
97
|
+
# data:
|
98
|
+
#
|
99
|
+
# { 'features' => (sent to exploder), 'run_id' => ID }
|
100
|
+
#
|
101
|
+
|
102
|
+
def run(data)
|
103
|
+
Dir.chdir(@scm.working_copy) do
|
104
|
+
@exploder.explode(data['features']) { |units| publish_units(data, units) }
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def publish_units(data, units)
|
109
|
+
scm = { :revision => @scm.current_revision, :url => @scm.url }
|
110
|
+
run = { :id => data['run_id'], :no_of_units => units.size}
|
111
|
+
|
112
|
+
units.each do |unit|
|
113
|
+
@broker.publish(
|
114
|
+
:jobs, {
|
115
|
+
:run => run,
|
116
|
+
:scm => scm,
|
117
|
+
:unit => unit,
|
118
|
+
}.to_json
|
119
|
+
)
|
120
|
+
|
121
|
+
log self.class, :published, unit
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
#
|
126
|
+
# Called whenever a result is received on the results queue
|
127
|
+
#
|
128
|
+
|
129
|
+
def result(message)
|
130
|
+
log self.class, :result, message['run']
|
131
|
+
@reporter.report message
|
132
|
+
end
|
133
|
+
|
134
|
+
def subscribe
|
135
|
+
@broker.subscribe :results do |message|
|
136
|
+
next unless message
|
137
|
+
result(JSON.parse(message))
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def start_webapp
|
142
|
+
@webapp.run method(:run)
|
143
|
+
end
|
144
|
+
|
145
|
+
end # Master
|
146
|
+
end # CukeQ
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module CukeQ
|
2
|
+
class Reporter
|
3
|
+
attr_reader :uri
|
4
|
+
|
5
|
+
def initialize(uri)
|
6
|
+
@uri = uri
|
7
|
+
end
|
8
|
+
|
9
|
+
def report(message)
|
10
|
+
EM::P::HttpClient.request(
|
11
|
+
:host => uri.host,
|
12
|
+
:port => uri.port,
|
13
|
+
:verb => "POST",
|
14
|
+
:request => uri.path.empty? ? "/" : uri.path,
|
15
|
+
:content => message.to_json
|
16
|
+
)
|
17
|
+
rescue => e # EM raises a RuntimeError..
|
18
|
+
log self.class, "error for #{uri}: #{e.message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
end # Reporter
|
22
|
+
end # CukeQ
|
data/lib/cukeq/runner.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
module CukeQ
|
2
|
+
class Runner
|
3
|
+
|
4
|
+
DEFAULT_TRIGGER_URI = URI.parse("http://localhost:9292/")
|
5
|
+
|
6
|
+
def self.execute(args)
|
7
|
+
opts = parse(args)
|
8
|
+
uri = opts.delete(:uri)
|
9
|
+
|
10
|
+
EM.run {
|
11
|
+
http = EM::P::HttpClient.request(
|
12
|
+
:host => uri.host,
|
13
|
+
:port => uri.port,
|
14
|
+
:verb => "POST",
|
15
|
+
:request => uri.path.empty? ? "/" : uri.path,
|
16
|
+
:content => opts.to_json
|
17
|
+
)
|
18
|
+
|
19
|
+
http.callback do |response|
|
20
|
+
log :success, response
|
21
|
+
EM.stop
|
22
|
+
end
|
23
|
+
|
24
|
+
http.errback do |error|
|
25
|
+
log :error, error[:status], uri.to_s
|
26
|
+
|
27
|
+
EM.stop
|
28
|
+
end
|
29
|
+
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def self.parse(argv)
|
36
|
+
options = {:uri => DEFAULT_TRIGGER_URI}
|
37
|
+
|
38
|
+
argv.extend OptionParser::Arguable
|
39
|
+
argv.options do |opts|
|
40
|
+
opts.on("-u", "--uri URI (default: #{DEFAULT_TRIGGER_URI})") do |str|
|
41
|
+
options[:uri] = URI.parse(str)
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.on("-i", "--id RUN_ID") do |str|
|
45
|
+
options[:run_id] = str
|
46
|
+
end
|
47
|
+
end.parse!
|
48
|
+
|
49
|
+
if argv.empty?
|
50
|
+
raise "must provide list of features"
|
51
|
+
end
|
52
|
+
|
53
|
+
unless options[:run_id]
|
54
|
+
raise "must provide --id"
|
55
|
+
end
|
56
|
+
|
57
|
+
options[:features] = argv
|
58
|
+
|
59
|
+
options
|
60
|
+
end
|
61
|
+
|
62
|
+
end # Runner
|
63
|
+
end # CukeQ
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module CukeQ
|
2
|
+
class ScenarioExploder
|
3
|
+
|
4
|
+
def explode(file_colon_lines)
|
5
|
+
yield file_colon_lines.map { |f| {:file => f} } # temporary
|
6
|
+
|
7
|
+
# # cwd is now the working copy of the project
|
8
|
+
# units = []
|
9
|
+
#
|
10
|
+
# # we do the parsing in a subprocess to avoid having to restart the master
|
11
|
+
# # whenever gherkin/cucumber is updated.
|
12
|
+
# #
|
13
|
+
# # The slaves should do the same. CukeQ is just passing things through.
|
14
|
+
# #
|
15
|
+
# IO.popen("-") do |pipe|
|
16
|
+
# if pipe
|
17
|
+
# while json = pipe.gets
|
18
|
+
# units << JSON.parse(json)
|
19
|
+
# end
|
20
|
+
# else
|
21
|
+
# file_colon_lines.each do |f|
|
22
|
+
# puts json_for(f)
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# yield units
|
28
|
+
end
|
29
|
+
|
30
|
+
end # ScenarioExploder
|
31
|
+
end # CukeQ
|
32
|
+
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require "tmpdir"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module CukeQ
|
7
|
+
class ScenarioRunner
|
8
|
+
|
9
|
+
def run(job, &callback)
|
10
|
+
scm = scm_for job
|
11
|
+
|
12
|
+
Dir.chdir scm.working_copy
|
13
|
+
|
14
|
+
run_job(scm.working_copy, job, callback)
|
15
|
+
rescue => ex
|
16
|
+
yield :success => false,
|
17
|
+
:error => ex.message,
|
18
|
+
:backtrace => ex.backtrace,
|
19
|
+
:run => job['run']
|
20
|
+
end
|
21
|
+
|
22
|
+
def scm_for(job)
|
23
|
+
url = job['scm']['url']
|
24
|
+
rev = job['scm']['revision']
|
25
|
+
|
26
|
+
scm = Scm.new(url)
|
27
|
+
unless scm.current_revision.to_s == rev.to_s
|
28
|
+
# TODO(jari): this doesn't ensure that current_revision == rev - it
|
29
|
+
# would also make sense to move the logic to Scm
|
30
|
+
scm.update {} # hmm.
|
31
|
+
end
|
32
|
+
|
33
|
+
scm
|
34
|
+
end
|
35
|
+
|
36
|
+
def run_job(working_copy, job, callback)
|
37
|
+
AsyncJob.new(working_copy, job, callback).run
|
38
|
+
end
|
39
|
+
|
40
|
+
end # ScenarioRunner
|
41
|
+
end # CukeQ
|
42
|
+
|
43
|
+
class AsyncJob
|
44
|
+
|
45
|
+
def initialize(working_copy, job, callback)
|
46
|
+
@job = job
|
47
|
+
@callback = callback
|
48
|
+
@result = {:success => true, :slave => CukeQ.identifier}
|
49
|
+
@invoked = false
|
50
|
+
@working_copy = working_copy
|
51
|
+
end
|
52
|
+
|
53
|
+
def run
|
54
|
+
parse_job
|
55
|
+
|
56
|
+
EventMachine.system3 command, &method(:child_finished)
|
57
|
+
rescue => ex
|
58
|
+
handle_exception(ex)
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def handle_exception(ex)
|
64
|
+
@result.merge!(:success => false, :error => ex.message, :backtrace => ex.backtrace, :cwd => Dir.pwd)
|
65
|
+
cleanup
|
66
|
+
invoke_callback
|
67
|
+
end
|
68
|
+
|
69
|
+
def invoke_callback
|
70
|
+
if @invoked
|
71
|
+
$stderr.puts "#{self} tried to invoke callback twice"
|
72
|
+
return
|
73
|
+
end
|
74
|
+
|
75
|
+
@invoked = true
|
76
|
+
@callback.call @result
|
77
|
+
end
|
78
|
+
|
79
|
+
def cleanup
|
80
|
+
FileUtils.rm_rf(output_file) if File.exist?(output_file)
|
81
|
+
end
|
82
|
+
|
83
|
+
def command
|
84
|
+
"cucumber -rfeatures --format Cucumber::Formatter::Json --out #{output_file} #{@feature_file}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def child_finished(stdout, stderr, status)
|
88
|
+
output = <<-OUT
|
89
|
+
stdout:
|
90
|
+
#{stdout}
|
91
|
+
|
92
|
+
stderr:
|
93
|
+
#{stderr}
|
94
|
+
OUT
|
95
|
+
|
96
|
+
@result.merge!(
|
97
|
+
:output => output,
|
98
|
+
:stderr => stderr,
|
99
|
+
:stdout => stdout,
|
100
|
+
:success => status.success?,
|
101
|
+
:exitcode => status.exitstatus,
|
102
|
+
:results => fetch_results,
|
103
|
+
:cwd => Dir.pwd
|
104
|
+
)
|
105
|
+
cleanup
|
106
|
+
invoke_callback
|
107
|
+
rescue => ex
|
108
|
+
handle_exception ex
|
109
|
+
end
|
110
|
+
|
111
|
+
def fetch_results
|
112
|
+
return unless File.exist?(output_file)
|
113
|
+
|
114
|
+
content = File.read(output_file)
|
115
|
+
begin
|
116
|
+
JSON.parse(content)
|
117
|
+
rescue JSON::ParserError => ex
|
118
|
+
raise JSON::ParserError, "#{ex.message} (#{content.inspect})"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def parse_job
|
123
|
+
@feature_file = @job['unit']['file']
|
124
|
+
@run = @job['run']
|
125
|
+
@scm = @job['scm']
|
126
|
+
|
127
|
+
@result.merge!(:feature_file => @feature_file, :run => @run, :scm => @scm)
|
128
|
+
end
|
129
|
+
|
130
|
+
def output_file
|
131
|
+
@output_file ||= "#{CukeQ.identifier}-#{@run['id']}.json"
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|