zspec 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.drone.yml +53 -0
- data/.gitignore +1 -0
- data/.rspec +1 -0
- data/.rubocop.yml +119 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +62 -0
- data/README.md +33 -0
- data/bin/zspec +7 -0
- data/docker-compose.yaml +41 -0
- data/hack/client/entrypoint.sh +13 -0
- data/hack/worker/entrypoint.sh +10 -0
- data/lib/zspec.rb +9 -0
- data/lib/zspec/cli.rb +113 -0
- data/lib/zspec/formatter.rb +80 -0
- data/lib/zspec/presenter.rb +172 -0
- data/lib/zspec/queue.rb +138 -0
- data/lib/zspec/scheduler.rb +42 -0
- data/lib/zspec/sink.rb +1 -0
- data/lib/zspec/sink/memory_sink.rb +86 -0
- data/lib/zspec/tracker.rb +101 -0
- data/lib/zspec/util.rb +23 -0
- data/lib/zspec/version.rb +3 -0
- data/lib/zspec/worker.rb +38 -0
- data/workflow.png +0 -0
- data/zspec.gemspec +33 -0
- metadata +167 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
require "rspec/core/formatters/base_formatter"
|
2
|
+
|
3
|
+
module ZSpec
|
4
|
+
class Formatter < ::RSpec::Core::Formatters::BaseFormatter
|
5
|
+
def initialize(queue:, tracker:, stdout:, message:)
|
6
|
+
super
|
7
|
+
@output_hash = { failures: [] }
|
8
|
+
@failed = false
|
9
|
+
@errors_outside_of_examples = false
|
10
|
+
@message = message
|
11
|
+
@queue = queue
|
12
|
+
@tracker = tracker
|
13
|
+
@stdout = stdout
|
14
|
+
end
|
15
|
+
|
16
|
+
def example_failed(failure)
|
17
|
+
@failed = true
|
18
|
+
@output_hash[:failures] << format_example(failure.example)
|
19
|
+
end
|
20
|
+
|
21
|
+
def dump_summary(summary)
|
22
|
+
@duration = summary.duration
|
23
|
+
# only set to true if there is a failure, otherwise it will override the failures from example_failed
|
24
|
+
if summary.errors_outside_of_examples_count.to_i > 0
|
25
|
+
@failed = true
|
26
|
+
@errors_outside_of_examples = true
|
27
|
+
end
|
28
|
+
@output_hash[:summary] = format_summary(summary)
|
29
|
+
end
|
30
|
+
|
31
|
+
def close(_notification)
|
32
|
+
@queue.resolve(
|
33
|
+
@failed,
|
34
|
+
@message,
|
35
|
+
@output_hash.to_json,
|
36
|
+
@stdout.string
|
37
|
+
)
|
38
|
+
@tracker.track_runtime(@message, @duration)
|
39
|
+
@tracker.track_failures(@output_hash[:failures]) if @failed
|
40
|
+
@tracker.track_sequence(@message)
|
41
|
+
raise if @errors_outside_of_examples
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def format_summary(summary)
|
47
|
+
{
|
48
|
+
duration: summary.duration,
|
49
|
+
load_time: summary.load_time,
|
50
|
+
file_path: @message,
|
51
|
+
example_count: summary.example_count,
|
52
|
+
failure_count: summary.failure_count,
|
53
|
+
pending_count: summary.pending_count,
|
54
|
+
errors_outside_of_examples_count: summary.errors_outside_of_examples_count
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
def format_example(example)
|
59
|
+
hash = {
|
60
|
+
id: example.id,
|
61
|
+
description: example.description,
|
62
|
+
full_description: example.full_description,
|
63
|
+
status: example.execution_result.status.to_s,
|
64
|
+
run_time: example.execution_result.run_time
|
65
|
+
}
|
66
|
+
e = example.exception
|
67
|
+
if e
|
68
|
+
hash[:exception] = {
|
69
|
+
class: e.class.name,
|
70
|
+
message: e.message,
|
71
|
+
backtrace: e.backtrace
|
72
|
+
}
|
73
|
+
end
|
74
|
+
hash
|
75
|
+
end
|
76
|
+
|
77
|
+
::RSpec::Core::Formatters.register self,
|
78
|
+
:close, :dump_summary, :example_failed
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
module ZSpec
|
2
|
+
class Presenter
|
3
|
+
def initialize(queue:, tracker:, display_count:, truncate_length:, out: $stdout)
|
4
|
+
::RSpec.configuration.tty = true
|
5
|
+
::RSpec.configuration.color = true
|
6
|
+
|
7
|
+
@queue = queue
|
8
|
+
@tracker = tracker
|
9
|
+
@display_count = display_count
|
10
|
+
@truncate_length = truncate_length
|
11
|
+
@out = out
|
12
|
+
|
13
|
+
@failures = []
|
14
|
+
@errors_outside_of_examples = []
|
15
|
+
@runtimes = []
|
16
|
+
|
17
|
+
@example_count = 0
|
18
|
+
@failure_count = 0
|
19
|
+
@pending_count = 0
|
20
|
+
@errors_outside_of_examples_count = 0
|
21
|
+
end
|
22
|
+
|
23
|
+
def poll_results
|
24
|
+
@queue.done_queue.each do |results, stdout|
|
25
|
+
next if results.nil?
|
26
|
+
present(::JSON.parse(results), stdout)
|
27
|
+
end
|
28
|
+
print_summary
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def present(results, stdout)
|
34
|
+
track_counts(results)
|
35
|
+
track_errors_outside_of_examples(results, stdout)
|
36
|
+
track_runtimes(results)
|
37
|
+
track_failures(results)
|
38
|
+
end
|
39
|
+
|
40
|
+
def print_summary
|
41
|
+
@out.puts ""
|
42
|
+
@out.puts "example_count: #{@example_count}"
|
43
|
+
@out.puts "failure_count: #{@failure_count}"
|
44
|
+
@out.puts "pending_count: #{@pending_count}"
|
45
|
+
@out.puts "errors_outside_of_examples_count: #{@errors_outside_of_examples_count}"
|
46
|
+
|
47
|
+
print_slow_specs
|
48
|
+
print_current_flaky_specs
|
49
|
+
print_alltime_flaky_specs
|
50
|
+
print_failed_specs
|
51
|
+
print_outside_of_examples
|
52
|
+
|
53
|
+
@out.flush
|
54
|
+
@failures.any? || @errors_outside_of_examples.any?
|
55
|
+
end
|
56
|
+
|
57
|
+
def print_outside_of_examples
|
58
|
+
if @errors_outside_of_examples.any?
|
59
|
+
@out.puts wrap("\nFIRST #{@display_count} ERRORS OUTSIDE OF EXAMPLES:", :bold)
|
60
|
+
@errors_outside_of_examples.take(@display_count).each do |message|
|
61
|
+
@out.puts wrap(truncated(message), :failure)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def print_failed_specs
|
67
|
+
if @failures.any?
|
68
|
+
@out.puts wrap("\nFIRST #{@display_count} FAILURES:", :bold)
|
69
|
+
@failures.take(@display_count).each_with_index do |example, index|
|
70
|
+
@out.puts wrap("#{example['id']}\n" \
|
71
|
+
"#{example['description']} (FAILED - #{index + 1})\n" \
|
72
|
+
"Exception - #{truncated(message_or_default(example))}\n" \
|
73
|
+
"Backtrace - #{truncated(backtrace_or_default(example).join("\n"))}\n",
|
74
|
+
:failure)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def print_current_flaky_specs
|
80
|
+
if @tracker.current_failures.any?
|
81
|
+
@out.puts wrap("\nFIRST #{@display_count} FLAKY SPECS CURRENT RUN:", :bold)
|
82
|
+
@tracker.current_failures.take(@display_count).each do |failure|
|
83
|
+
@out.puts "#{failure['message']} failed #{failure['count']} times. "
|
84
|
+
if failure["sequence"].any?
|
85
|
+
@out.puts " PREVIOUS FILES:"
|
86
|
+
failure["sequence"].each { |spec| @out.puts " #{spec}" }
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def print_alltime_flaky_specs
|
93
|
+
if @tracker.alltime_failures.any?
|
94
|
+
@out.puts wrap("\nFIRST #{@display_count} FLAKY SPECS LAST #{humanize(@tracker.threshold).upcase}:", :bold)
|
95
|
+
@tracker.alltime_failures.take(@display_count).each do |failure|
|
96
|
+
@out.puts "#{failure['message']} failed #{failure['count']} times. " \
|
97
|
+
"last failure was #{humanize(Time.now.to_i - failure['last_failure'])} ago.\n"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def print_slow_specs
|
103
|
+
@out.puts wrap("\n#{@display_count} SLOWEST FILES CURRENT RUN:", :bold)
|
104
|
+
@runtimes.sort_by { |h| h[:duration] }.reverse.take(@display_count).each do |h|
|
105
|
+
@out.puts "#{h[:file_path]} finished in #{format_duration(h[:duration])} " \
|
106
|
+
"(file took #{format_duration(h[:load_time])} to load)\n"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def track_failures(results)
|
111
|
+
results["failures"].each do |example|
|
112
|
+
@failures << example
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def track_counts(results)
|
117
|
+
@example_count += results["summary"]["example_count"].to_i
|
118
|
+
@failure_count += results["summary"]["failure_count"].to_i
|
119
|
+
@pending_count += results["summary"]["pending_count"].to_i
|
120
|
+
@errors_outside_of_examples_count += results["summary"]["errors_outside_of_examples_count"].to_i
|
121
|
+
end
|
122
|
+
|
123
|
+
def track_errors_outside_of_examples(results, stdout)
|
124
|
+
unless stdout.nil? || stdout.empty? || results["summary"]["errors_outside_of_examples_count"].to_i == 0
|
125
|
+
@errors_outside_of_examples << stdout
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def track_runtimes(results)
|
130
|
+
@runtimes << {
|
131
|
+
file_path: results["summary"]["file_path"],
|
132
|
+
duration: results["summary"]["duration"],
|
133
|
+
load_time: results["summary"]["load_time"]
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
def humanize(secs)
|
138
|
+
[[60, :seconds], [60, :minutes], [24, :hours], [Float::INFINITY, :days]].map { |count, name|
|
139
|
+
if secs > 0
|
140
|
+
secs, n = secs.divmod(count)
|
141
|
+
"#{n.to_i} #{name}" unless n.to_i == 0
|
142
|
+
end
|
143
|
+
}.compact.reverse.join(" ")
|
144
|
+
end
|
145
|
+
|
146
|
+
def message_or_default(example)
|
147
|
+
example.dig("exception", "message") || ""
|
148
|
+
end
|
149
|
+
|
150
|
+
def backtrace_or_default(example)
|
151
|
+
example.dig("exception", "backtrace") || []
|
152
|
+
end
|
153
|
+
|
154
|
+
def truncated(message)
|
155
|
+
unless message.empty?
|
156
|
+
if message.length < @truncate_length
|
157
|
+
message
|
158
|
+
else
|
159
|
+
message.slice(0..@truncate_length) + "... (truncated)"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def format_duration(duration)
|
165
|
+
::RSpec::Core::Formatters::Helpers.format_duration(duration)
|
166
|
+
end
|
167
|
+
|
168
|
+
def wrap(message, symbol)
|
169
|
+
::RSpec::Core::Formatters::ConsoleCodes.wrap(message, symbol)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
data/lib/zspec/queue.rb
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
require "zspec/util"
|
2
|
+
|
3
|
+
module ZSpec
|
4
|
+
class Queue
|
5
|
+
attr_reader :counter_name, :pending_queue_name, :processing_queue_name,
|
6
|
+
:done_queue_name, :metadata_hash_name, :workers_ready_key_name
|
7
|
+
|
8
|
+
include ZSpec::Util
|
9
|
+
|
10
|
+
def initialize(sink:, build_prefix:, retries:, timeout:)
|
11
|
+
@sink = sink
|
12
|
+
@retries = retries.to_i
|
13
|
+
@timeout = timeout.to_i
|
14
|
+
@counter_name = build_prefix + ":count"
|
15
|
+
@pending_queue_name = build_prefix + ":pending"
|
16
|
+
@processing_queue_name = build_prefix + ":processing"
|
17
|
+
@done_queue_name = build_prefix + ":done"
|
18
|
+
@metadata_hash_name = build_prefix + ":metadata"
|
19
|
+
@workers_ready_key_name = build_prefix + ":ready"
|
20
|
+
end
|
21
|
+
|
22
|
+
def cleanup(expire_seconds = EXPIRE_SECONDS)
|
23
|
+
@sink.expire(@counter_name, expire_seconds)
|
24
|
+
@sink.expire(@pending_queue_name, expire_seconds)
|
25
|
+
@sink.expire(@processing_queue_name, expire_seconds)
|
26
|
+
@sink.expire(@done_queue_name, expire_seconds)
|
27
|
+
@sink.expire(@metadata_hash_name, expire_seconds)
|
28
|
+
@sink.expire(@workers_ready_key_name, expire_seconds)
|
29
|
+
end
|
30
|
+
|
31
|
+
def enqueue(messages)
|
32
|
+
messages.each do |message|
|
33
|
+
@sink.lpush(@pending_queue_name, message)
|
34
|
+
@sink.incr(@counter_name)
|
35
|
+
end
|
36
|
+
@sink.set(@workers_ready_key_name, true)
|
37
|
+
end
|
38
|
+
|
39
|
+
def done_queue
|
40
|
+
Enumerator.new do |yielder|
|
41
|
+
until workers_ready? && complete?
|
42
|
+
expire_processing
|
43
|
+
|
44
|
+
_list, message = @sink.brpop(@done_queue_name, timeout: 1)
|
45
|
+
if message.nil?
|
46
|
+
yielder << [nil, nil]
|
47
|
+
next
|
48
|
+
end
|
49
|
+
|
50
|
+
if @sink.hget(@metadata_hash_name, dedupe_key(message))
|
51
|
+
yielder << [nil, nil]
|
52
|
+
next
|
53
|
+
end
|
54
|
+
|
55
|
+
results = @sink.hget(@metadata_hash_name, results_key(message))
|
56
|
+
if results.nil?
|
57
|
+
yielder << [nil, nil]
|
58
|
+
next
|
59
|
+
end
|
60
|
+
|
61
|
+
stdout = @sink.hget(@metadata_hash_name, stdout_key(message))
|
62
|
+
|
63
|
+
@sink.hset(@metadata_hash_name, dedupe_key(message), true)
|
64
|
+
@sink.decr(@counter_name)
|
65
|
+
|
66
|
+
yielder << [results, stdout]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def pending_queue
|
72
|
+
Enumerator.new do |yielder|
|
73
|
+
until workers_ready? && complete?
|
74
|
+
message = @sink.brpoplpush(@pending_queue_name, @processing_queue_name, timeout: 1)
|
75
|
+
if message.nil?
|
76
|
+
yielder << nil
|
77
|
+
next
|
78
|
+
end
|
79
|
+
@sink.hset(@metadata_hash_name, timeout_key(message), @sink.time.first)
|
80
|
+
yielder << message
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def resolve(failed, message, results, stdout)
|
86
|
+
if failed && (count = retry_count(message)) && (count < @retries)
|
87
|
+
retry_message(message, count)
|
88
|
+
else
|
89
|
+
resolve_message(message, results, stdout)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def expire_processing
|
96
|
+
processing.each do |message|
|
97
|
+
next unless expired?(message)
|
98
|
+
|
99
|
+
@sink.lrem(@processing_queue_name, 0, message)
|
100
|
+
@sink.rpush(@pending_queue_name, message)
|
101
|
+
@sink.hdel(@metadata_hash_name, timeout_key(message))
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def workers_ready?
|
106
|
+
@sink.get(@workers_ready_key_name)
|
107
|
+
end
|
108
|
+
|
109
|
+
def processing
|
110
|
+
@sink.lrange(@processing_queue_name, 0, -1)
|
111
|
+
end
|
112
|
+
|
113
|
+
def complete?
|
114
|
+
@sink.get(@counter_name).to_i == 0
|
115
|
+
end
|
116
|
+
|
117
|
+
def retry_count(message)
|
118
|
+
@sink.hget(@metadata_hash_name, retry_key(message)).to_i
|
119
|
+
end
|
120
|
+
|
121
|
+
def expired?(message)
|
122
|
+
proccess_time = @sink.hget(@metadata_hash_name, timeout_key(message)).to_i
|
123
|
+
(@sink.time.first - proccess_time) > @timeout
|
124
|
+
end
|
125
|
+
|
126
|
+
def resolve_message(message, results, stdout)
|
127
|
+
@sink.hset(@metadata_hash_name, stdout_key(message), stdout)
|
128
|
+
@sink.hset(@metadata_hash_name, results_key(message), results)
|
129
|
+
@sink.lrem(@processing_queue_name, 0, message)
|
130
|
+
@sink.lpush(@done_queue_name, message)
|
131
|
+
end
|
132
|
+
|
133
|
+
def retry_message(message, count)
|
134
|
+
@sink.hdel(@metadata_hash_name, timeout_key(message))
|
135
|
+
@sink.hset(@metadata_hash_name, retry_key(message), count + 1)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ZSpec
|
2
|
+
class Scheduler
|
3
|
+
def initialize(queue:, tracker:)
|
4
|
+
@queue = queue
|
5
|
+
@tracker = tracker
|
6
|
+
end
|
7
|
+
|
8
|
+
def schedule(args)
|
9
|
+
enqueue(
|
10
|
+
extract(args)
|
11
|
+
.uniq
|
12
|
+
.map(&method(:normalize))
|
13
|
+
.sort_by(&method(:runtime))
|
14
|
+
.reverse
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def extract(args)
|
21
|
+
configuration = ::RSpec.configuration
|
22
|
+
::RSpec::Core::ConfigurationOptions.new([args]).configure(configuration)
|
23
|
+
configuration.files_to_run
|
24
|
+
end
|
25
|
+
|
26
|
+
def runtimes
|
27
|
+
@runtimes ||= @tracker.all_runtimes
|
28
|
+
end
|
29
|
+
|
30
|
+
def runtime(example)
|
31
|
+
runtimes[example].to_i || 0
|
32
|
+
end
|
33
|
+
|
34
|
+
def enqueue(examples)
|
35
|
+
@queue.enqueue(examples)
|
36
|
+
end
|
37
|
+
|
38
|
+
def normalize(file)
|
39
|
+
file.sub("#{Dir.pwd}/", "./")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/zspec/sink.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require_relative "sink/memory_sink"
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module ZSpec
|
2
|
+
module Sink
|
3
|
+
class MemorySink
|
4
|
+
def initialize(state:, expirations:)
|
5
|
+
@state = state
|
6
|
+
@expirations = expirations
|
7
|
+
end
|
8
|
+
|
9
|
+
def time
|
10
|
+
[@state[:time]]
|
11
|
+
end
|
12
|
+
|
13
|
+
def expire(key, seconds)
|
14
|
+
@expirations[key] = seconds
|
15
|
+
end
|
16
|
+
|
17
|
+
def lpush(key, value)
|
18
|
+
(@state[key] ||= []).unshift(value)
|
19
|
+
end
|
20
|
+
|
21
|
+
def lrem(key, n, value)
|
22
|
+
(@state[key] ||= []).delete(value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def lrange(key, start, stop)
|
26
|
+
(@state[key] ||= [])[start..stop]
|
27
|
+
end
|
28
|
+
|
29
|
+
def rpush(key, value)
|
30
|
+
(@state[key] ||= []).push(value)
|
31
|
+
end
|
32
|
+
|
33
|
+
def rpop(key)
|
34
|
+
(@state[key] ||= []).pop
|
35
|
+
end
|
36
|
+
|
37
|
+
def brpop(key, timeout: 0)
|
38
|
+
return [], rpop(key)
|
39
|
+
end
|
40
|
+
|
41
|
+
def brpoplpush(source, destination, timeout: 0)
|
42
|
+
_list, message = brpop(source)
|
43
|
+
lpush(destination, message)
|
44
|
+
message
|
45
|
+
end
|
46
|
+
|
47
|
+
def hget(key, field)
|
48
|
+
(@state[key] ||= {})[field]
|
49
|
+
end
|
50
|
+
|
51
|
+
def hgetall(key)
|
52
|
+
@state[key] ||= {}
|
53
|
+
end
|
54
|
+
|
55
|
+
def hset(key, field, value)
|
56
|
+
(@state[key] ||= {})[field] = value
|
57
|
+
end
|
58
|
+
|
59
|
+
def hdel(key, field)
|
60
|
+
(@state[key] ||= {}).delete(field)
|
61
|
+
end
|
62
|
+
|
63
|
+
def hincrby(key, field, value)
|
64
|
+
(@state[key] ||= {})[field] = (hget(key, field) || 0) + 1
|
65
|
+
end
|
66
|
+
|
67
|
+
def incr(key)
|
68
|
+
@state[key] ||= 0
|
69
|
+
@state[key] += 1
|
70
|
+
end
|
71
|
+
|
72
|
+
def decr(key)
|
73
|
+
@state[key] ||= 0
|
74
|
+
@state[key] -= 1
|
75
|
+
end
|
76
|
+
|
77
|
+
def set(key, value)
|
78
|
+
@state[key] = value
|
79
|
+
end
|
80
|
+
|
81
|
+
def get(key)
|
82
|
+
@state[key]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|