zspec 1.0.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 +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
|