rox-client-rspec 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|