rox-client-rspec 0.3.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.
@@ -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