rox-client-rspec 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,86 @@
1
+ require 'paint'
2
+ require 'fileutils'
3
+ require 'rspec/core/formatters/base_formatter'
4
+
5
+ module RoxClient::RSpec
6
+
7
+ class Formatter < RSpec::Core::Formatters::BaseFormatter
8
+
9
+ def initialize *args
10
+ super *args
11
+
12
+ config = RoxClient::RSpec.config
13
+ @client = Client.new config.server, config.client_options
14
+ @test_run = TestRun.new config.project
15
+
16
+ @groups = []
17
+ end
18
+
19
+ def start example_count
20
+ # TODO: measure milliseconds
21
+ @start_time = Time.now
22
+ end
23
+
24
+ def example_group_started group
25
+ @groups << group
26
+ end
27
+
28
+ def example_group_finished group
29
+ @groups.pop
30
+ end
31
+
32
+ def example_started example
33
+ @current_time = Time.now
34
+ end
35
+
36
+ def example_passed example
37
+ add_result example, true
38
+ end
39
+
40
+ def example_failed example
41
+ add_result example, false
42
+ end
43
+
44
+ def stop
45
+ end_time = Time.now
46
+ @test_run.end_time = end_time.to_i * 1000
47
+ @test_run.duration = ((end_time - @start_time) * 1000).round
48
+ end
49
+
50
+ def dump_summary duration, example_count, failure_count, pending_count
51
+ @client.process @test_run
52
+ end
53
+
54
+ private
55
+
56
+ def add_result example, successful
57
+
58
+ options = {
59
+ passed: successful,
60
+ duration: ((Time.now - @current_time) * 1000).round
61
+ }
62
+ options[:message] = failure_message(example) unless successful
63
+
64
+ @test_run.add_result example, @groups, options
65
+ end
66
+
67
+ def failure_message example
68
+ exception = example.execution_result[:exception]
69
+ Array.new.tap do |a|
70
+ a << full_example_name(example)
71
+ a << "Failure/Error: #{read_failed_line(exception, example).strip}"
72
+ a << " #{exception.class.name}:" unless exception.class.name =~ /RSpec/
73
+ exception.message.to_s.split("\n").each do |line|
74
+ a << " #{line}"
75
+ end
76
+ format_backtrace(example.execution_result[:exception].backtrace, example).each do |backtrace_info|
77
+ a << "# #{backtrace_info}"
78
+ end
79
+ end.join "\n"
80
+ end
81
+
82
+ def full_example_name example
83
+ (@groups.collect{ |g| g.description.strip } << example.description.strip).join ' '
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,32 @@
1
+
2
+ module RoxClient::RSpec
3
+
4
+ class Project
5
+ # TODO: remove project name once API v0 is dead
6
+ attr_accessor :name, :version, :api_id, :category, :tags, :tickets
7
+
8
+ def initialize options = {}
9
+ update options
10
+ end
11
+
12
+ def update options = {}
13
+ %w(name version api_id category).each do |k|
14
+ instance_variable_set "@#{k}", options[k.to_sym] ? options[k.to_sym].to_s : nil if options.key? k.to_sym
15
+ end
16
+ @tags = wrap(options[:tags]).compact if options.key? :tags
17
+ @tickets = wrap(options[:tickets]).compact if options.key? :tickets
18
+ end
19
+
20
+ def validate!
21
+ required = { "name" => @name, "version" => @version, "API identifier" => @api_id }
22
+ missing = required.inject([]){ |memo,(k,v)| v.to_s.strip.length <= 0 ? memo << k : memo }
23
+ raise PayloadError.new("Missing project options: #{missing.join ', '}") if missing.any?
24
+ end
25
+
26
+ private
27
+
28
+ def wrap a
29
+ a.kind_of?(Array) ? a : [ a ]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,97 @@
1
+ require 'oj'
2
+ require 'httparty'
3
+
4
+ module RoxClient::RSpec
5
+
6
+ class Server
7
+ attr_reader :name, :api_url, :api_key_id, :api_key_secret, :api_version, :project_api_id
8
+
9
+ class Error < RoxClient::RSpec::Error
10
+ attr_reader :response
11
+
12
+ def initialize msg, response = nil
13
+ super msg
14
+ @response = response
15
+ end
16
+ end
17
+
18
+ def initialize options = {}
19
+ @name = options[:name].to_s.strip
20
+ @api_url = options[:api_url].to_s if options[:api_url]
21
+ @api_key_id = options[:api_key_id].to_s if options[:api_key_id]
22
+ @api_key_secret = options[:api_key_secret].to_s if options[:api_key_secret]
23
+ @api_version = options[:api_version] || 1
24
+ @project_api_id = options[:project_api_id].to_s if options[:project_api_id]
25
+ end
26
+
27
+ def payload_options
28
+ { version: @api_version }
29
+ end
30
+
31
+ def upload payload
32
+ validate!
33
+
34
+ uri = payload_uri
35
+ body = Oj.dump payload, mode: :strict
36
+
37
+ res = case @api_version
38
+ when 0
39
+ HTTParty.post uri, body: body
40
+ else
41
+ HTTParty.post uri, body: body, headers: payload_headers.merge(authentication_headers)
42
+ end
43
+
44
+ if res.code != 202
45
+ raise Error.new("Expected HTTP 202 Accepted when submitting payload, got #{res.code}", res)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def validate!
52
+
53
+ raise Error.new("Server #{@name} requires $ROX_RUNNER_KEY to be set (API v0)") if @api_version == 0 and !ENV['ROX_RUNNER_KEY']
54
+
55
+ required = { "apiUrl" => @api_url }
56
+ required.merge!({ "apiKeyId" => @api_key_id, "apiKeySecret" => @api_key_secret, "projectApiId" => @project_api_id }) if @api_version >= 1
57
+ missing = required.inject([]){ |memo,(k,v)| v.to_s.strip.length <= 0 ? memo << k : memo }
58
+ raise Error.new("Server #{@name} is missing the following options: #{missing.join ', '}") if missing.any?
59
+ end
60
+
61
+ def payload_headers
62
+ { 'Content-Type' => 'application/vnd.lotaris.rox.payload.v1+json' }
63
+ end
64
+
65
+ def payload_uri
66
+ case @api_version
67
+
68
+ when 0
69
+ "#{@api_url}/v1/payload"
70
+
71
+ else
72
+
73
+ # get api root
74
+ res = HTTParty.get @api_url, headers: authentication_headers
75
+ if res.code != 200
76
+ raise Error.new("Expected HTTP 200 OK status code for API root, got #{res.code}", res)
77
+ elsif res.content_type != 'application/hal+json'
78
+ raise Error.new("Expected API root in the application/hal+json content type, got #{res.content_type}", res)
79
+ end
80
+
81
+ body = Oj.load res.body, mode: :strict
82
+
83
+ links = body['_links'] || {}
84
+ if !links.key?('v1:test-payloads')
85
+ raise Error.new("Expected API root to have a v1:test-payloads link", res)
86
+ end
87
+
88
+ # extract payload uri
89
+ links['v1:test-payloads']['href']
90
+ end
91
+ end
92
+
93
+ def authentication_headers
94
+ { 'Authorization' => %|RoxApiKey id="#{@api_key_id}" secret="#{@api_key_secret}"| }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,62 @@
1
+ require 'fileutils'
2
+ require 'rake/tasklib'
3
+
4
+ module RoxClient::RSpec
5
+
6
+ class Tasks < ::Rake::TaskLib
7
+
8
+ def initialize
9
+
10
+ namespace :spec do
11
+
12
+ namespace :rox do
13
+
14
+ desc "Generate a test run UID to group test results in ROX Center (stored in an environment variable)"
15
+ task :uid do
16
+ trace do
17
+ uid = uid_manager.generate_uid_to_env
18
+ puts Paint["ROX - Generated UID for test run: #{uid}", :cyan]
19
+ end
20
+ end
21
+
22
+ namespace :uid do
23
+
24
+ desc "Generate a test run UID to group test results in ROX Center (stored in a file)"
25
+ task :file do
26
+ trace do
27
+ uid = uid_manager.generate_uid_to_file
28
+ puts Paint["ROX - Generated UID for test run: #{uid}", :cyan]
29
+ end
30
+ end
31
+
32
+ desc "Clean the test run UID (file and environment variable)"
33
+ task :clean do
34
+ trace do
35
+ uid_manager.clean_uid
36
+ puts Paint["ROX - Cleaned test run UID", :cyan]
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def trace &block
47
+ if Rake.application.options.trace
48
+ block.call
49
+ else
50
+ begin
51
+ block.call
52
+ rescue UID::Error => e
53
+ warn Paint["ROX - #{e.message}", :red]
54
+ end
55
+ end
56
+ end
57
+
58
+ def uid_manager
59
+ UID.new RoxClient::RSpec.config.client_options
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ require 'fileutils'
2
+ require 'digest/sha2'
3
+
4
+ module RoxClient::RSpec
5
+
6
+ class TestPayload
7
+
8
+ class Error < RoxClient::RSpec::Error; end
9
+
10
+ def initialize run
11
+ @run = run
12
+ end
13
+
14
+ def to_h options = {}
15
+ case options[:version]
16
+ when 0
17
+ { 'r' => [ @run.to_h(options) ] }
18
+ else # version 1 by default
19
+ @run.to_h options
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,114 @@
1
+
2
+ module RoxClient::RSpec
3
+
4
+ class TestResult
5
+ attr_reader :key, :name, :category, :tags, :tickets, :duration, :message
6
+
7
+ def initialize project, example, groups = [], options = {}
8
+
9
+ @category = project.category
10
+ @tags = project.tags
11
+ @tickets = project.tickets
12
+
13
+ @grouped = extract_grouped example, groups
14
+
15
+ [ :key, :name, :category, :tags, :tickets ].each do |attr|
16
+ instance_variable_set "@#{attr}".to_sym, send("extract_#{attr}".to_sym, example, groups)
17
+ end
18
+
19
+ @passed = !!options[:passed]
20
+ @duration = options[:duration]
21
+ @message = options[:message]
22
+ end
23
+
24
+ def passed?
25
+ @passed
26
+ end
27
+
28
+ def grouped?
29
+ @grouped
30
+ end
31
+
32
+ def update options = {}
33
+ @passed &&= options[:passed]
34
+ @duration += options[:duration]
35
+ @message = [ @message, options[:message] ].select{ |m| m }.join("\n\n") if options[:message]
36
+ end
37
+
38
+ def to_h options = {}
39
+ {
40
+ 'k' => @key,
41
+ 'p' => @passed,
42
+ 'd' => @duration
43
+ }.tap do |h|
44
+
45
+ h['m'] = @message if @message
46
+
47
+ cache = options[:cache]
48
+ first = !cache || !cache.known?(self)
49
+ stale = !first && cache.stale?(self)
50
+ h['n'] = @name if stale or first
51
+ h['c'] = @category if stale or (first and @category)
52
+ h['g'] = @tags if stale or (first and !@tags.empty?)
53
+ h['t'] = @tickets if stale or (first and !@tickets.empty?)
54
+ end
55
+ end
56
+
57
+ def self.extract_grouped example, groups = []
58
+ !!groups.collect{ |g| meta(g)[:grouped] }.compact.last
59
+ end
60
+
61
+ def self.extract_key example, groups = []
62
+ (groups.collect{ |g| meta(g)[:key] } << meta(example)[:key]).compact.last
63
+ end
64
+
65
+ def self.meta holder
66
+ meta = holder.metadata[:rox] || {}
67
+ if meta.kind_of? String
68
+ { key: meta }
69
+ elsif meta.kind_of? Hash
70
+ meta
71
+ else
72
+ {}
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def meta *args
79
+ self.class.meta *args
80
+ end
81
+
82
+ def extract_grouped *args
83
+ self.class.extract_grouped *args
84
+ end
85
+
86
+ def extract_key *args
87
+ self.class.extract_key *args
88
+ end
89
+
90
+ def extract_name example, groups = []
91
+ parts = groups.dup
92
+ parts = parts[0, parts.index{ |p| meta(p)[:grouped] } + 1] if @grouped
93
+ parts << example unless @grouped
94
+ parts.collect{ |p| p.description.strip }.join ' '
95
+ end
96
+
97
+ def extract_category example, groups = []
98
+ cat = (groups.collect{ |g| meta(g)[:category] }.unshift(@category) << meta(example)[:category]).compact.last
99
+ cat ? cat.to_s : nil
100
+ end
101
+
102
+ def extract_tags example, groups = []
103
+ (wrap(@tags) + groups.collect{ |g| wrap meta(g)[:tags] } + (wrap meta(example)[:tags])).flatten.compact.uniq.collect(&:to_s)
104
+ end
105
+
106
+ def extract_tickets example, groups = []
107
+ (wrap(@tickets) + groups.collect{ |g| wrap meta(g)[:tickets] } + (wrap meta(example)[:tickets])).flatten.compact.uniq.collect(&:to_s)
108
+ end
109
+
110
+ def wrap a
111
+ a.kind_of?(Array) ? a : [ a ]
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,104 @@
1
+
2
+ module RoxClient::RSpec
3
+
4
+ class TestRun
5
+ # TODO: remove end time once API v0 is dead
6
+ attr_reader :results, :project
7
+ attr_accessor :end_time, :duration, :uid
8
+
9
+ def initialize project
10
+ @results = []
11
+ @project = project
12
+ end
13
+
14
+ def add_result example, groups = [], options = {}
15
+
16
+ if TestResult.extract_grouped(example, groups) and (existing_result = @results.find{ |r| r.grouped? && r.key == TestResult.extract_key(example, groups) })
17
+ existing_result.update options
18
+ else
19
+ @results << TestResult.new(@project, example, groups, options)
20
+ end
21
+ end
22
+
23
+ def results_without_key
24
+ @results.select{ |r| !r.key or r.key.to_s.strip.empty? }
25
+ end
26
+
27
+ def results_by_key
28
+ @results.inject({}) do |memo,r|
29
+ (memo[r.key] ||= []) << r unless !r.key or r.key.to_s.strip.empty?
30
+ memo
31
+ end
32
+ end
33
+
34
+ def to_h options = {}
35
+ validate!
36
+
37
+ case options[:version]
38
+ when 0
39
+ {
40
+ 'r' => ENV['ROX_RUNNER_KEY'],
41
+ 'e' => @end_time,
42
+ 'd' => @duration,
43
+ 'j' => @project.name,
44
+ 'v' => @project.version,
45
+ 't' => @results.collect{ |r| r.to_h options }
46
+ }.tap do |h|
47
+ h['u'] = @uid if @uid
48
+ end
49
+ else # version 1 by default
50
+ {
51
+ 'd' => @duration,
52
+ 'r' => [
53
+ {
54
+ 'j' => @project.api_id,
55
+ 'v' => @project.version,
56
+ 't' => @results.collect{ |r| r.to_h options }
57
+ }
58
+ ]
59
+ }.tap do |h|
60
+ h['u'] = @uid if @uid
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def validate!
68
+ # TODO: validate duration
69
+
70
+ raise PayloadError.new("Missing project") if !@project
71
+ @project.validate!
72
+
73
+ validate_no_results_without_key
74
+ validate_no_duplicate_keys
75
+ end
76
+
77
+ def validate_no_duplicate_keys
78
+
79
+ results_with_duplicate_key = results_by_key.select{ |k,r| r.length >= 2 }
80
+ return if results_with_duplicate_key.none?
81
+
82
+ msg = "the following keys are used by multiple test results".tap do |s|
83
+ results_with_duplicate_key.each_pair do |key,results|
84
+ s << "\n - #{key}"
85
+ results.each{ |r| s << "\n - #{r.name}" }
86
+ end
87
+ end
88
+
89
+ raise PayloadError.new(msg)
90
+ end
91
+
92
+ def validate_no_results_without_key
93
+
94
+ keyless = results_without_key
95
+ return if keyless.empty?
96
+
97
+ msg = "the following test results are missing a key".tap do |s|
98
+ keyless.each{ |r| s << "\n - #{r.name}" }
99
+ end
100
+
101
+ raise PayloadError.new(msg)
102
+ end
103
+ end
104
+ end