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.
- checksums.yaml +15 -0
- data/Gemfile +21 -0
- data/LICENSE.txt +20 -0
- data/README.md +148 -0
- data/VERSION +1 -0
- data/lib/rox-client-rspec.rb +14 -0
- data/lib/rox-client-rspec/cache.rb +60 -0
- data/lib/rox-client-rspec/client.rb +119 -0
- data/lib/rox-client-rspec/config.rb +178 -0
- data/lib/rox-client-rspec/formatter.rb +86 -0
- data/lib/rox-client-rspec/project.rb +32 -0
- data/lib/rox-client-rspec/server.rb +97 -0
- data/lib/rox-client-rspec/tasks.rb +62 -0
- data/lib/rox-client-rspec/test_payload.rb +23 -0
- data/lib/rox-client-rspec/test_result.rb +114 -0
- data/lib/rox-client-rspec/test_run.rb +104 -0
- data/lib/rox-client-rspec/uid.rb +60 -0
- metadata +215 -0
@@ -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
|