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.
Files changed (45) hide show
  1. data/.autotest +1 -0
  2. data/.document +5 -0
  3. data/.gitignore +21 -0
  4. data/LICENSE +20 -0
  5. data/README.rdoc +73 -0
  6. data/Rakefile +68 -0
  7. data/VERSION +1 -0
  8. data/bin/cukeq +34 -0
  9. data/features/cukeq.feature +15 -0
  10. data/features/example1.feature +1 -0
  11. data/features/example2.feature +1 -0
  12. data/features/step_definitions/cukeq_steps.rb +22 -0
  13. data/features/support/cukeq_helper.rb +73 -0
  14. data/features/support/env.rb +13 -0
  15. data/features/support/report_app.rb +36 -0
  16. data/lib/cukeq.rb +45 -0
  17. data/lib/cukeq/broker.rb +65 -0
  18. data/lib/cukeq/em/system3.rb +53 -0
  19. data/lib/cukeq/job_clearer.rb +19 -0
  20. data/lib/cukeq/master.rb +146 -0
  21. data/lib/cukeq/reporter.rb +22 -0
  22. data/lib/cukeq/runner.rb +63 -0
  23. data/lib/cukeq/scenario_exploder.rb +32 -0
  24. data/lib/cukeq/scenario_runner.rb +134 -0
  25. data/lib/cukeq/scm.rb +51 -0
  26. data/lib/cukeq/scm/git_bridge.rb +36 -0
  27. data/lib/cukeq/scm/shell_svn_bridge.rb +65 -0
  28. data/lib/cukeq/scm/svn_bridge.rb +77 -0
  29. data/lib/cukeq/slave.rb +114 -0
  30. data/lib/cukeq/webapp.rb +38 -0
  31. data/spec/cukeq/broker_spec.rb +78 -0
  32. data/spec/cukeq/cukeq_spec.rb +10 -0
  33. data/spec/cukeq/master_spec.rb +153 -0
  34. data/spec/cukeq/reporter_spec.rb +29 -0
  35. data/spec/cukeq/runner_spec.rb +11 -0
  36. data/spec/cukeq/scenario_exploder_spec.rb +17 -0
  37. data/spec/cukeq/scenario_runner_spec.rb +32 -0
  38. data/spec/cukeq/scm/git_bridge_spec.rb +52 -0
  39. data/spec/cukeq/scm/svn_bridge_spec.rb +5 -0
  40. data/spec/cukeq/scm_spec.rb +48 -0
  41. data/spec/cukeq/slave_spec.rb +86 -0
  42. data/spec/cukeq/webapp_spec.rb +37 -0
  43. data/spec/spec.opts +1 -0
  44. data/spec/spec_helper.rb +12 -0
  45. metadata +228 -0
@@ -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
@@ -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
@@ -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