cukeq 0.0.1.dev
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/.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
|