rspecq 0.2.2 → 0.7.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 +4 -4
- data/CHANGELOG.md +42 -2
- data/README.md +49 -4
- data/Rakefile +1 -1
- data/bin/rspecq +61 -6
- data/lib/rspecq/formatters/failure_recorder.rb +15 -6
- data/lib/rspecq/formatters/job_timing_recorder.rb +3 -0
- data/lib/rspecq/formatters/worker_heartbeat_recorder.rb +0 -1
- data/lib/rspecq/queue.rb +71 -8
- data/lib/rspecq/reporter.rb +28 -14
- data/lib/rspecq/version.rb +1 -1
- data/lib/rspecq/worker.rb +54 -19
- metadata +18 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4337137edc0f3ee2c22d4f9cd2547ab31d4ddc0b043b49848c7b513ae98094a9
|
|
4
|
+
data.tar.gz: 6de02e1ce31cb7dff6af9d4862f1dceebfbfbbd10063329c9d3d058d32bf8f06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: beb1b013a1d78e8c18b473ef33d286224a92ef46ae3944f99296cc3d2d13193309b0efb020307fa3e9139b7fd88eb9a9111ad526026762ccf0b17ba892b3ddb4
|
|
7
|
+
data.tar.gz: 178481fe5b707cefa25426ec6b2ead691dd69494a6b9869eaf8e438b78e100c5dd8c0c9caab2dd4b792c18e5c931e4e7231cc971b77fa048c14420f33978fe2a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,12 +4,52 @@ Breaking changes are prefixed with a "[BREAKING]" label.
|
|
|
4
4
|
|
|
5
5
|
## master (unreleased)
|
|
6
6
|
|
|
7
|
+
## 0.7.0 (2021-04-1)
|
|
8
|
+
|
|
9
|
+
- New cli parameter `reproduction`.
|
|
10
|
+
When passed, primary worker publishes the queue in the same order as passed
|
|
11
|
+
in the command.
|
|
12
|
+
- Reporter now includes a reproduction command for flaky tests.
|
|
13
|
+
|
|
14
|
+
## 0.6.0 (2021-03-23)
|
|
15
|
+
|
|
16
|
+
- New cli parameter `seed`.
|
|
17
|
+
The seed is passed to the RSpec command.
|
|
18
|
+
|
|
19
|
+
## 0.5.0 (2021-02-05)
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- New cli parameter `queue_wait_timeout`.
|
|
24
|
+
It configured the time a queue can wait to be ready. The env equivalent
|
|
25
|
+
is `RSPECQ_QUEUE_WAIT_TIMEOUT`. [#51](https://github.com/skroutz/rspecq/pull/51)
|
|
26
|
+
|
|
27
|
+
## 0.4.0 (2020-10-07)
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Builds can be configured to terminate after a specified number of failures,
|
|
32
|
+
using the `--fail-fast` option.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## 0.3.0 (2020-10-05)
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Providing a Redis URL is now possible using the `--redis-url` option
|
|
40
|
+
[[#40](https://github.com/skroutz/rspecq/pull/40)]
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- [DEPRECATION] The `--redis` option is now deprecated. Use `--redis-host`
|
|
45
|
+
instead [[#40](https://github.com/skroutz/rspecq/pull/40)]
|
|
46
|
+
|
|
7
47
|
## 0.2.2 (2020-09-10)
|
|
8
48
|
|
|
9
49
|
### Fixed
|
|
10
|
-
- Worker would fail if application code was writing to stderr
|
|
50
|
+
- Worker would fail if application code was writing to stderr
|
|
11
51
|
[[#35](https://github.com/skroutz/rspecq/pull/35)]
|
|
12
|
-
|
|
52
|
+
|
|
13
53
|
## 0.2.1 (2020-09-09)
|
|
14
54
|
|
|
15
55
|
### Changed
|
data/README.md
CHANGED
|
@@ -26,8 +26,8 @@ and [ci-queue](https://github.com/Shopify/ci-queue).
|
|
|
26
26
|
- Handles intermittent worker failures (e.g. network hiccups, faulty hardware etc.)
|
|
27
27
|
by detecting non-responsive workers and requeing their jobs. See [*Worker failures*](#worker-failures)
|
|
28
28
|
- Sentry integration for monitoring build-level events. See [*Sentry integration*](#sentry-integration).
|
|
29
|
-
- [PLANNED] StatsD integration for various build-level metrics and insights.
|
|
30
29
|
See [#2](https://github.com/skroutz/rspecq/issues/2).
|
|
30
|
+
- Automatic termination of builds after a certain amount of failures. See [*Fail-fast*](#fail-fast).
|
|
31
31
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
@@ -64,17 +64,46 @@ USAGE:
|
|
|
64
64
|
OPTIONS:
|
|
65
65
|
-b, --build ID A unique identifier for the build. Should be common among workers participating in the same build.
|
|
66
66
|
-w, --worker ID An identifier for the worker. Workers participating in the same build should have distinct IDs.
|
|
67
|
-
|
|
67
|
+
--seed SEED The RSpec seed. Passing the seed can be helpful in many ways i.e reproduction and testing.
|
|
68
|
+
-r, --redis HOST --redis is deprecated. Use --redis-host or --redis-url instead. Redis host to connect to (default: 127.0.0.1).
|
|
69
|
+
--redis-host HOST Redis host to connect to (default: 127.0.0.1).
|
|
70
|
+
--redis-url URL Redis URL to connect to (e.g.: redis://127.0.0.1:6379/0).
|
|
68
71
|
--update-timings Update the global job timings key with the timings of this build. Note: This key is used as the basis for job scheduling.
|
|
69
72
|
--file-split-threshold N Split spec files slower than N seconds and schedule them as individual examples.
|
|
70
73
|
--report Enable reporter mode: do not pull tests off the queue; instead print build progress and exit when it's finished.
|
|
71
74
|
Exits with a non-zero status code if there were any failures.
|
|
72
75
|
--report-timeout N Fail if build is not finished after N seconds. Only applicable if --report is enabled (default: 3600).
|
|
73
76
|
--max-requeues N Retry failed examples up to N times before considering them legit failures (default: 3).
|
|
77
|
+
--queue-wait-timeout N Time to wait for a queue to be ready before considering it failed (default: 30).
|
|
78
|
+
--fail-fast N Abort build with a non-zero status code after N failed examples.
|
|
79
|
+
--reproduction Enable reproduction mode: Publish files and examples in the exact order given in the command. Incompatible with --timings.
|
|
74
80
|
-h, --help Show this message.
|
|
75
81
|
-v, --version Print the version and exit.
|
|
76
82
|
```
|
|
77
83
|
|
|
84
|
+
You can set most options using ENV variables:
|
|
85
|
+
|
|
86
|
+
```shell
|
|
87
|
+
$ RSPECQ_BUILD=123 RSPECQ_WORKDER=foo1 rspecq spec/
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Supported ENV variables
|
|
91
|
+
|
|
92
|
+
| Name | Desc |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `RSPECQ_BUILD` | Build ID |
|
|
95
|
+
| `RSPECQ_WORKER` | Worker ID |
|
|
96
|
+
| `RSPECQ_SEED` | RSpec seed |
|
|
97
|
+
| `RSPECQ_REDIS` | Redis HOST |
|
|
98
|
+
| `RSPECQ_UPDATE_TIMINGS` | Timings |
|
|
99
|
+
| `RSPECQ_FILE_SPLIT_THRESHOLD` | File split threshold |
|
|
100
|
+
| `RSPECQ_REPORT` | Report |
|
|
101
|
+
| `RSPECQ_REPORT_TIMEOUT` | Report Timeout |
|
|
102
|
+
| `RSPECQ_MAX_REQUEUES` | Max requests |
|
|
103
|
+
| `RSPECQ_QUEUE_WAIT_TIMEOUT` | Queue wait timeout |
|
|
104
|
+
| `RSPECQ_REDIS_URL` | Redis URL |
|
|
105
|
+
| `RSPECQ_FAIL_FAST` | Fail fast |
|
|
106
|
+
|
|
78
107
|
### Sentry integration
|
|
79
108
|
|
|
80
109
|
RSpecQ can optionally emit build events to a
|
|
@@ -86,7 +115,6 @@ This is convenient for monitoring important warnings/errors that may impact
|
|
|
86
115
|
build times, such as the fact that no previous timings were found and
|
|
87
116
|
therefore job scheduling was effectively random for a particular build.
|
|
88
117
|
|
|
89
|
-
|
|
90
118
|
## How it works
|
|
91
119
|
|
|
92
120
|
The core design is almost identical to ci-queue so please refer to its
|
|
@@ -113,7 +141,7 @@ For example, a single file may need 10 minutes to run while all other
|
|
|
113
141
|
files finish after 8 minutes. This would cause all but one workers to be
|
|
114
142
|
sitting idle for 2 minutes.
|
|
115
143
|
|
|
116
|
-
To overcome this issue, RSpecQ can
|
|
144
|
+
To overcome this issue, RSpecQ can split files which their execution time is
|
|
117
145
|
above a certain threshold (set with the `--file-split-threshold` option)
|
|
118
146
|
and instead schedule them as individual examples.
|
|
119
147
|
|
|
@@ -131,6 +159,16 @@ final report.
|
|
|
131
159
|
Flaky tests are also detected and printed as such in the final report. They are
|
|
132
160
|
also emitted to Sentry (see [Sentry integration](#sentry-integration)).
|
|
133
161
|
|
|
162
|
+
### Fail-fast
|
|
163
|
+
|
|
164
|
+
In order to prevent large suites running for a long time with a lot of
|
|
165
|
+
failures, a threshold can be set to control the number of failed examples that
|
|
166
|
+
will render the build unsuccessful. This is in par with RSpec's
|
|
167
|
+
[--fail-fast](https://relishapp.com/rspec/rspec-core/docs/command-line/fail-fast-option).
|
|
168
|
+
|
|
169
|
+
This feature is disabled by default, and can be controlled via the
|
|
170
|
+
`--fail-fast` command line option.
|
|
171
|
+
|
|
134
172
|
### Worker failures
|
|
135
173
|
|
|
136
174
|
It's not uncommon for CI processes to encounter unrecoverable failures for
|
|
@@ -202,6 +240,13 @@ To enable verbose output in the tests:
|
|
|
202
240
|
$ RSPECQ_DEBUG=1 bundle exec rake
|
|
203
241
|
```
|
|
204
242
|
|
|
243
|
+
## Redis
|
|
244
|
+
|
|
245
|
+
RSpecQ by design doesn't expire its keys from Redis. It is left to the user
|
|
246
|
+
to configure the Redis server to do so; see
|
|
247
|
+
[Using Redis as an LRU cache](https://redis.io/topics/lru-cache) for more info.
|
|
248
|
+
|
|
249
|
+
You can do this from a configuration file or with `redis-cli`.
|
|
205
250
|
|
|
206
251
|
## License
|
|
207
252
|
|
data/Rakefile
CHANGED
data/bin/rspecq
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
require "optparse"
|
|
3
3
|
require "rspecq"
|
|
4
4
|
|
|
5
|
-
DEFAULT_REDIS_HOST = "127.0.0.1"
|
|
5
|
+
DEFAULT_REDIS_HOST = "127.0.0.1".freeze
|
|
6
6
|
DEFAULT_REPORT_TIMEOUT = 3600 # 1 hour
|
|
7
7
|
DEFAULT_MAX_REQUEUES = 3
|
|
8
|
+
DEFAULT_QUEUE_WAIT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_FAIL_FAST = 0
|
|
8
10
|
|
|
9
11
|
def env_set?(var)
|
|
10
12
|
["1", "true"].include?(ENV[var])
|
|
@@ -36,11 +38,27 @@ OptionParser.new do |o|
|
|
|
36
38
|
opts[:worker] = v
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
o.on("--seed SEED", "The RSpec seed. Passing the seed can be helpful in " \
|
|
42
|
+
"many ways i.e reproduction and testing.") do |v|
|
|
43
|
+
opts[:seed] = v
|
|
44
|
+
end
|
|
45
|
+
|
|
39
46
|
o.on("-r", "--redis HOST", "Redis host to connect to " \
|
|
40
47
|
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
|
48
|
+
puts "DEPRECATION: --redis is deprecated. Use --redis-host or --redis-url instead"
|
|
41
49
|
opts[:redis_host] = v
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
o.on("--redis-host HOST", "Redis host to connect to " \
|
|
53
|
+
"(default: #{DEFAULT_REDIS_HOST}).") do |v|
|
|
54
|
+
opts[:redis_host] = v
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
o.on("--redis-url URL", "The URL of the Redis host to connect to " \
|
|
58
|
+
"(e.g.: redis://127.0.0.1:6379/0).") do |v|
|
|
59
|
+
opts[:redis_url] = v
|
|
60
|
+
end
|
|
61
|
+
|
|
44
62
|
o.on("--update-timings", "Update the global job timings key with the " \
|
|
45
63
|
"timings of this build. Note: This key is used as the basis for job " \
|
|
46
64
|
"scheduling.") do |v|
|
|
@@ -54,7 +72,7 @@ OptionParser.new do |o|
|
|
|
54
72
|
|
|
55
73
|
o.on("--report", "Enable reporter mode: do not pull tests off the queue; " \
|
|
56
74
|
"instead print build progress and exit when it's " \
|
|
57
|
-
"finished.\n#{o.summary_indent*9} " \
|
|
75
|
+
"finished.\n#{o.summary_indent * 9} " \
|
|
58
76
|
"Exits with a non-zero status code if there were any " \
|
|
59
77
|
"failures.") do |v|
|
|
60
78
|
opts[:report] = v
|
|
@@ -72,6 +90,23 @@ OptionParser.new do |o|
|
|
|
72
90
|
opts[:max_requeues] = v
|
|
73
91
|
end
|
|
74
92
|
|
|
93
|
+
o.on("--queue-wait-timeout N", Integer, "Time to wait for a queue to be " \
|
|
94
|
+
"ready before considering it failed " \
|
|
95
|
+
"(default: #{DEFAULT_QUEUE_WAIT_TIMEOUT}).") do |v|
|
|
96
|
+
opts[:queue_wait_timeout] = v
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
o.on("--fail-fast N", Integer, "Abort build with a non-zero status code " \
|
|
100
|
+
"after N failed examples.") do |v|
|
|
101
|
+
opts[:fail_fast] = v
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
o.on("--reproduction", "Enable reproduction mode: run rspec on the given files " \
|
|
105
|
+
"and examples in the exact order they are given. Incompatible with " \
|
|
106
|
+
"--timings.") do |v|
|
|
107
|
+
opts[:reproduction] = v
|
|
108
|
+
end
|
|
109
|
+
|
|
75
110
|
o.on_tail("-h", "--help", "Show this message.") do
|
|
76
111
|
puts o
|
|
77
112
|
exit
|
|
@@ -85,21 +120,37 @@ end.parse!
|
|
|
85
120
|
|
|
86
121
|
opts[:build] ||= ENV["RSPECQ_BUILD"]
|
|
87
122
|
opts[:worker] ||= ENV["RSPECQ_WORKER"]
|
|
123
|
+
opts[:seed] ||= ENV["RSPECQ_SEED"]
|
|
88
124
|
opts[:redis_host] ||= ENV["RSPECQ_REDIS"] || DEFAULT_REDIS_HOST
|
|
89
125
|
opts[:timings] ||= env_set?("RSPECQ_UPDATE_TIMINGS")
|
|
90
|
-
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] ||
|
|
126
|
+
opts[:file_split_threshold] ||= Integer(ENV["RSPECQ_FILE_SPLIT_THRESHOLD"] || 9_999_999)
|
|
91
127
|
opts[:report] ||= env_set?("RSPECQ_REPORT")
|
|
92
128
|
opts[:report_timeout] ||= Integer(ENV["RSPECQ_REPORT_TIMEOUT"] || DEFAULT_REPORT_TIMEOUT)
|
|
93
129
|
opts[:max_requeues] ||= Integer(ENV["RSPECQ_MAX_REQUEUES"] || DEFAULT_MAX_REQUEUES)
|
|
130
|
+
opts[:queue_wait_timeout] ||= Integer(ENV["RSPECQ_QUEUE_WAIT_TIMEOUT"] || DEFAULT_QUEUE_WAIT_TIMEOUT)
|
|
131
|
+
opts[:redis_url] ||= ENV["RSPECQ_REDIS_URL"]
|
|
132
|
+
opts[:fail_fast] ||= Integer(ENV["RSPECQ_FAIL_FAST"] || DEFAULT_FAIL_FAST)
|
|
133
|
+
opts[:reproduction] ||= env_set?("RSPECQ_REPRODUCTION")
|
|
94
134
|
|
|
135
|
+
# rubocop:disable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
|
95
136
|
raise OptionParser::MissingArgument.new(:build) if opts[:build].nil?
|
|
96
137
|
raise OptionParser::MissingArgument.new(:worker) if !opts[:report] && opts[:worker].nil?
|
|
138
|
+
# rubocop:enable Style/RaiseArgs, Layout/EmptyLineAfterGuardClause
|
|
139
|
+
|
|
140
|
+
redis_opts = {}
|
|
141
|
+
|
|
142
|
+
if opts[:redis_url]
|
|
143
|
+
redis_opts[:url] = opts[:redis_url]
|
|
144
|
+
else
|
|
145
|
+
redis_opts[:host] = opts[:redis_host]
|
|
146
|
+
end
|
|
97
147
|
|
|
98
148
|
if opts[:report]
|
|
99
149
|
reporter = RSpecQ::Reporter.new(
|
|
100
150
|
build_id: opts[:build],
|
|
101
151
|
timeout: opts[:report_timeout],
|
|
102
|
-
|
|
152
|
+
redis_opts: redis_opts,
|
|
153
|
+
queue_wait_timeout: opts[:queue_wait_timeout]
|
|
103
154
|
)
|
|
104
155
|
|
|
105
156
|
reporter.report
|
|
@@ -107,12 +158,16 @@ else
|
|
|
107
158
|
worker = RSpecQ::Worker.new(
|
|
108
159
|
build_id: opts[:build],
|
|
109
160
|
worker_id: opts[:worker],
|
|
110
|
-
|
|
161
|
+
redis_opts: redis_opts
|
|
111
162
|
)
|
|
112
163
|
|
|
113
|
-
worker.files_or_dirs_to_run = ARGV
|
|
164
|
+
worker.files_or_dirs_to_run = ARGV if ARGV.any?
|
|
114
165
|
worker.populate_timings = opts[:timings]
|
|
115
166
|
worker.file_split_threshold = opts[:file_split_threshold]
|
|
116
167
|
worker.max_requeues = opts[:max_requeues]
|
|
168
|
+
worker.queue_wait_timeout = opts[:queue_wait_timeout]
|
|
169
|
+
worker.fail_fast = opts[:fail_fast]
|
|
170
|
+
worker.seed = Integer(opts[:seed]) if opts[:seed]
|
|
171
|
+
worker.reproduction = opts[:reproduction]
|
|
117
172
|
worker.work
|
|
118
173
|
end
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
module RSpecQ
|
|
2
2
|
module Formatters
|
|
3
|
+
# Persists failed examples information (i.e. message and backtrace), so
|
|
4
|
+
# that they can be reported to the end user by the Reporter.
|
|
5
|
+
#
|
|
6
|
+
# Also persists non-example error information (e.g. a syntax error that
|
|
7
|
+
# in a spec file).
|
|
3
8
|
class FailureRecorder
|
|
4
|
-
def initialize(queue, job, max_requeues)
|
|
9
|
+
def initialize(queue, job, max_requeues, worker_id)
|
|
5
10
|
@queue = queue
|
|
6
11
|
@job = job
|
|
7
12
|
@colorizer = RSpec::Core::Formatters::ConsoleCodes
|
|
8
13
|
@non_example_error_recorded = false
|
|
9
14
|
@max_requeues = max_requeues
|
|
15
|
+
@worker_id = worker_id
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
# Here we're notified about errors occuring outside of examples.
|
|
@@ -25,7 +31,7 @@ module RSpecQ
|
|
|
25
31
|
def example_failed(notification)
|
|
26
32
|
example = notification.example
|
|
27
33
|
|
|
28
|
-
if @queue.requeue_job(example
|
|
34
|
+
if @queue.requeue_job(example, @max_requeues, @worker_id)
|
|
29
35
|
# HACK: try to avoid picking the job we just requeued; we want it
|
|
30
36
|
# to be picked up by a different worker
|
|
31
37
|
sleep 0.5
|
|
@@ -33,16 +39,19 @@ module RSpecQ
|
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
presenter = RSpec::Core::Formatters::ExceptionPresenter.new(
|
|
36
|
-
example.exception, example
|
|
42
|
+
example.exception, example
|
|
43
|
+
)
|
|
37
44
|
|
|
38
45
|
msg = presenter.fully_formatted(nil, @colorizer)
|
|
39
46
|
msg << "\n"
|
|
40
47
|
msg << @colorizer.wrap(
|
|
41
|
-
"bin/rspec #{example.location_rerun_argument}",
|
|
42
|
-
RSpec.configuration.failure_color
|
|
48
|
+
"bin/rspec --seed #{RSpec.configuration.seed} #{example.location_rerun_argument}",
|
|
49
|
+
RSpec.configuration.failure_color
|
|
50
|
+
)
|
|
43
51
|
|
|
44
52
|
msg << @colorizer.wrap(
|
|
45
|
-
" # #{example.full_description}", RSpec.configuration.detail_color
|
|
53
|
+
" # #{example.full_description}", RSpec.configuration.detail_color
|
|
54
|
+
)
|
|
46
55
|
|
|
47
56
|
@queue.record_example_failure(notification.example.id, msg)
|
|
48
57
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
module RSpecQ
|
|
2
2
|
module Formatters
|
|
3
|
+
# Persists each job's timing (in seconds). Those timings are used when
|
|
4
|
+
# determining the ordering in which jobs are scheduled (slower jobs will
|
|
5
|
+
# be enqueued first).
|
|
3
6
|
class JobTimingRecorder
|
|
4
7
|
def initialize(queue, job)
|
|
5
8
|
@queue = queue
|
data/lib/rspecq/queue.rb
CHANGED
|
@@ -51,8 +51,12 @@ module RSpecQ
|
|
|
51
51
|
REQUEUE_JOB = <<~LUA.freeze
|
|
52
52
|
local key_queue_unprocessed = KEYS[1]
|
|
53
53
|
local key_requeues = KEYS[2]
|
|
54
|
+
local key_requeued_job_original_worker = KEYS[3]
|
|
55
|
+
local key_job_location = KEYS[4]
|
|
54
56
|
local job = ARGV[1]
|
|
55
57
|
local max_requeues = ARGV[2]
|
|
58
|
+
local original_worker = ARGV[3]
|
|
59
|
+
local location = ARGV[4]
|
|
56
60
|
|
|
57
61
|
local requeued_times = redis.call('hget', key_requeues, job)
|
|
58
62
|
if requeued_times and requeued_times >= max_requeues then
|
|
@@ -60,7 +64,9 @@ module RSpecQ
|
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
redis.call('lpush', key_queue_unprocessed, job)
|
|
67
|
+
redis.call('hset', key_requeued_job_original_worker, job, original_worker)
|
|
63
68
|
redis.call('hincrby', key_requeues, job, 1)
|
|
69
|
+
redis.call('hset', key_job_location, job, location)
|
|
64
70
|
|
|
65
71
|
return true
|
|
66
72
|
LUA
|
|
@@ -70,15 +76,16 @@ module RSpecQ
|
|
|
70
76
|
|
|
71
77
|
attr_reader :redis
|
|
72
78
|
|
|
73
|
-
def initialize(build_id, worker_id,
|
|
79
|
+
def initialize(build_id, worker_id, redis_opts)
|
|
74
80
|
@build_id = build_id
|
|
75
81
|
@worker_id = worker_id
|
|
76
|
-
@redis = Redis.new(
|
|
82
|
+
@redis = Redis.new(redis_opts.merge(id: worker_id))
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
# NOTE: jobs will be processed from head to tail (lpop)
|
|
80
|
-
def publish(jobs)
|
|
86
|
+
def publish(jobs, fail_fast = 0)
|
|
81
87
|
@redis.multi do
|
|
88
|
+
@redis.hset(key_queue_config, "fail_fast", fail_fast)
|
|
82
89
|
@redis.rpush(key_queue_unprocessed, jobs)
|
|
83
90
|
@redis.set(key_queue_status, STATUS_READY)
|
|
84
91
|
end.first
|
|
@@ -116,6 +123,7 @@ module RSpecQ
|
|
|
116
123
|
@redis.multi do
|
|
117
124
|
@redis.hdel(key_queue_running, @worker_id)
|
|
118
125
|
@redis.sadd(key_queue_processed, job)
|
|
126
|
+
@redis.rpush(key("queue", "jobs_per_worker", @worker_id), job)
|
|
119
127
|
end
|
|
120
128
|
end
|
|
121
129
|
|
|
@@ -124,16 +132,41 @@ module RSpecQ
|
|
|
124
132
|
#
|
|
125
133
|
# Returns nil if the job hit the requeue limit and therefore was not
|
|
126
134
|
# requeued and should be considered a failure.
|
|
127
|
-
def requeue_job(
|
|
135
|
+
def requeue_job(example, max_requeues, original_worker_id)
|
|
128
136
|
return false if max_requeues.zero?
|
|
129
137
|
|
|
138
|
+
job = example.id
|
|
139
|
+
location = example.location_rerun_argument
|
|
140
|
+
|
|
130
141
|
@redis.eval(
|
|
131
142
|
REQUEUE_JOB,
|
|
132
|
-
keys: [key_queue_unprocessed, key_requeues],
|
|
133
|
-
argv: [job, max_requeues]
|
|
143
|
+
keys: [key_queue_unprocessed, key_requeues, key("requeued_job_original_worker"), key("job_location")],
|
|
144
|
+
argv: [job, max_requeues, original_worker_id, location]
|
|
134
145
|
)
|
|
135
146
|
end
|
|
136
147
|
|
|
148
|
+
def save_worker_seed(worker, seed)
|
|
149
|
+
@redis.hset(key("worker_seed"), worker, seed)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def job_location(job)
|
|
153
|
+
@redis.hget(key("job_location"), job)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def failed_job_worker(job)
|
|
157
|
+
redis.hget(key("requeued_job_original_worker"), job)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def job_rerun_command(job)
|
|
161
|
+
worker = failed_job_worker(job)
|
|
162
|
+
jobs = redis.lrange(key("queue", "jobs_per_worker", worker), 0, -1)
|
|
163
|
+
seed = redis.hget(key("worker_seed"), worker)
|
|
164
|
+
|
|
165
|
+
"DISABLE_SPRING=1 DISABLE_BOOTSNAP=1 bin/rspecq --build 1 " \
|
|
166
|
+
"--worker foo --seed #{seed} --max-requeues 0 --fail-fast 1 " \
|
|
167
|
+
"--reproduction #{jobs.join(' ')}"
|
|
168
|
+
end
|
|
169
|
+
|
|
137
170
|
def record_example_failure(example_id, message)
|
|
138
171
|
@redis.hset(key_failures, example_id, message)
|
|
139
172
|
end
|
|
@@ -209,9 +242,10 @@ module RSpecQ
|
|
|
209
242
|
@redis.get(key_queue_status) == STATUS_READY
|
|
210
243
|
end
|
|
211
244
|
|
|
212
|
-
def wait_until_published(timeout=30)
|
|
245
|
+
def wait_until_published(timeout = 30)
|
|
213
246
|
(timeout * 10).times do
|
|
214
247
|
return if published?
|
|
248
|
+
|
|
215
249
|
sleep 0.1
|
|
216
250
|
end
|
|
217
251
|
|
|
@@ -232,7 +266,9 @@ module RSpecQ
|
|
|
232
266
|
# after being retried). Must be called after the build is complete,
|
|
233
267
|
# otherwise an exception will be raised.
|
|
234
268
|
def flaky_jobs
|
|
235
|
-
|
|
269
|
+
if !exhausted? && !build_failed_fast?
|
|
270
|
+
raise "Queue is not yet exhausted"
|
|
271
|
+
end
|
|
236
272
|
|
|
237
273
|
requeued = @redis.hkeys(key_requeues)
|
|
238
274
|
|
|
@@ -241,11 +277,38 @@ module RSpecQ
|
|
|
241
277
|
requeued - @redis.hkeys(key_failures)
|
|
242
278
|
end
|
|
243
279
|
|
|
280
|
+
# Returns the number of failures that will trigger the build to fail-fast.
|
|
281
|
+
# Returns 0 if this feature is disabled and nil if the Queue is not yet
|
|
282
|
+
# published
|
|
283
|
+
def fail_fast
|
|
284
|
+
return nil unless published?
|
|
285
|
+
|
|
286
|
+
@fail_fast ||= Integer(@redis.hget(key_queue_config, "fail_fast"))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Returns true if the number of failed tests, has surpassed the threshold
|
|
290
|
+
# to render the run unsuccessful and the build should be terminated.
|
|
291
|
+
def build_failed_fast?
|
|
292
|
+
if fail_fast.nil? || fail_fast.zero?
|
|
293
|
+
return false
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
@redis.multi do
|
|
297
|
+
@redis.hlen(key_failures)
|
|
298
|
+
@redis.hlen(key_errors)
|
|
299
|
+
end.inject(:+) >= fail_fast
|
|
300
|
+
end
|
|
301
|
+
|
|
244
302
|
# redis: STRING [STATUS_INITIALIZING, STATUS_READY]
|
|
245
303
|
def key_queue_status
|
|
246
304
|
key("queue", "status")
|
|
247
305
|
end
|
|
248
306
|
|
|
307
|
+
# redis: HASH<config_key => config_value>
|
|
308
|
+
def key_queue_config
|
|
309
|
+
key("queue", "config")
|
|
310
|
+
end
|
|
311
|
+
|
|
249
312
|
# redis: LIST<job>
|
|
250
313
|
def key_queue_unprocessed
|
|
251
314
|
key("queue", "unprocessed")
|
data/lib/rspecq/reporter.rb
CHANGED
|
@@ -9,18 +9,19 @@ module RSpecQ
|
|
|
9
9
|
#
|
|
10
10
|
# Reporters are readers of the queue.
|
|
11
11
|
class Reporter
|
|
12
|
-
def initialize(build_id:, timeout:,
|
|
12
|
+
def initialize(build_id:, timeout:, redis_opts:, queue_wait_timeout: 30)
|
|
13
13
|
@build_id = build_id
|
|
14
14
|
@timeout = timeout
|
|
15
|
-
@queue = Queue.new(build_id, "reporter",
|
|
15
|
+
@queue = Queue.new(build_id, "reporter", redis_opts)
|
|
16
|
+
@queue_wait_timeout = queue_wait_timeout
|
|
16
17
|
|
|
17
18
|
# We want feedback to be immediattely printed to CI users, so
|
|
18
19
|
# we disable buffering.
|
|
19
|
-
|
|
20
|
+
$stdout.sync = true
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def report
|
|
23
|
-
@queue.wait_until_published
|
|
24
|
+
@queue.wait_until_published(@queue_wait_timeout)
|
|
24
25
|
|
|
25
26
|
finished = false
|
|
26
27
|
|
|
@@ -28,7 +29,7 @@ module RSpecQ
|
|
|
28
29
|
failure_heading_printed = false
|
|
29
30
|
|
|
30
31
|
tests_duration = measure_duration do
|
|
31
|
-
@timeout.times do
|
|
32
|
+
@timeout.times do
|
|
32
33
|
@queue.example_failures.each do |job, rspec_output|
|
|
33
34
|
next if reported_failures[job]
|
|
34
35
|
|
|
@@ -41,7 +42,7 @@ module RSpecQ
|
|
|
41
42
|
puts failure_formatted(rspec_output)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
unless @queue.exhausted? || @queue.build_failed_fast?
|
|
45
46
|
sleep 1
|
|
46
47
|
next
|
|
47
48
|
end
|
|
@@ -83,6 +84,13 @@ module RSpecQ
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
summary = ""
|
|
87
|
+
if @queue.build_failed_fast?
|
|
88
|
+
summary << "\n\n"
|
|
89
|
+
summary << "The limit of #{@queue.fail_fast} failures has been reached\n"
|
|
90
|
+
summary << "Aborting..."
|
|
91
|
+
summary << "\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
86
94
|
summary << failed_examples_section if !failures.empty?
|
|
87
95
|
|
|
88
96
|
errors.each { |_job, msg| summary << msg }
|
|
@@ -99,7 +107,14 @@ module RSpecQ
|
|
|
99
107
|
if !flaky_jobs.empty?
|
|
100
108
|
summary << "\n\n"
|
|
101
109
|
summary << "Flaky jobs detected (count=#{flaky_jobs.count}):\n"
|
|
102
|
-
flaky_jobs.each
|
|
110
|
+
flaky_jobs.each do |j|
|
|
111
|
+
summary << RSpec::Core::Formatters::ConsoleCodes.wrap(
|
|
112
|
+
"#{@queue.job_location(j)} @ #{@queue.failed_job_worker(j)}\n",
|
|
113
|
+
RSpec.configuration.pending_color
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
summary << "#{@queue.job_rerun_command(j)}\n\n\n"
|
|
117
|
+
end
|
|
103
118
|
end
|
|
104
119
|
|
|
105
120
|
summary
|
|
@@ -117,16 +132,15 @@ module RSpecQ
|
|
|
117
132
|
return if jobs.empty?
|
|
118
133
|
|
|
119
134
|
jobs.each do |job|
|
|
120
|
-
filename = job.sub(/\[.+\]/,
|
|
135
|
+
filename = job.sub(/\[.+\]/, "")[%r{spec/.+}].split(":")[0]
|
|
121
136
|
|
|
122
137
|
extra = {
|
|
123
138
|
build: @build_id,
|
|
124
139
|
build_timeout: @timeout,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
build_duration: build_duration
|
|
140
|
+
build_duration: build_duration,
|
|
141
|
+
location: @queue.job_location(job),
|
|
142
|
+
rerun_command: @queue.job_rerun_command(job),
|
|
143
|
+
worker: @queue.failed_job_worker(job)
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
tags = {
|
|
@@ -136,7 +150,7 @@ module RSpecQ
|
|
|
136
150
|
|
|
137
151
|
Raven.capture_message(
|
|
138
152
|
"Flaky test in #{filename}",
|
|
139
|
-
level:
|
|
153
|
+
level: "warning",
|
|
140
154
|
extra: extra,
|
|
141
155
|
tags: tags
|
|
142
156
|
)
|
data/lib/rspecq/version.rb
CHANGED
data/lib/rspecq/worker.rb
CHANGED
|
@@ -40,17 +40,39 @@ module RSpecQ
|
|
|
40
40
|
# Defaults to 3
|
|
41
41
|
attr_accessor :max_requeues
|
|
42
42
|
|
|
43
|
+
# Stop the execution after N failed tests. Do not stop at any point when
|
|
44
|
+
# set to 0.
|
|
45
|
+
#
|
|
46
|
+
# Defaults to 0
|
|
47
|
+
attr_accessor :fail_fast
|
|
48
|
+
|
|
49
|
+
# Time to wait for a queue to be published.
|
|
50
|
+
#
|
|
51
|
+
# Defaults to 30
|
|
52
|
+
attr_accessor :queue_wait_timeout
|
|
53
|
+
|
|
54
|
+
# The RSpec seed
|
|
55
|
+
attr_accessor :seed
|
|
56
|
+
|
|
57
|
+
# Reproduction flag. If true, worker will publish files in the exact order
|
|
58
|
+
# given in the command.
|
|
59
|
+
attr_accessor :reproduction
|
|
60
|
+
|
|
43
61
|
attr_reader :queue
|
|
44
62
|
|
|
45
|
-
def initialize(build_id:, worker_id:,
|
|
63
|
+
def initialize(build_id:, worker_id:, redis_opts:)
|
|
46
64
|
@build_id = build_id
|
|
47
65
|
@worker_id = worker_id
|
|
48
|
-
@queue = Queue.new(build_id, worker_id,
|
|
66
|
+
@queue = Queue.new(build_id, worker_id, redis_opts)
|
|
67
|
+
@fail_fast = 0
|
|
49
68
|
@files_or_dirs_to_run = "spec"
|
|
50
69
|
@populate_timings = false
|
|
51
|
-
@file_split_threshold =
|
|
70
|
+
@file_split_threshold = 999_999
|
|
52
71
|
@heartbeat_updated_at = nil
|
|
53
72
|
@max_requeues = 3
|
|
73
|
+
@queue_wait_timeout = 30
|
|
74
|
+
@seed = srand && srand % 0xFFFF
|
|
75
|
+
@reproduction = false
|
|
54
76
|
|
|
55
77
|
RSpec::Core::Formatters.register(Formatters::JobTimingRecorder, :dump_summary)
|
|
56
78
|
RSpec::Core::Formatters.register(Formatters::ExampleCountRecorder, :dump_summary)
|
|
@@ -62,13 +84,16 @@ module RSpecQ
|
|
|
62
84
|
puts "Working for build #{@build_id} (worker=#{@worker_id})"
|
|
63
85
|
|
|
64
86
|
try_publish_queue!(queue)
|
|
65
|
-
queue.wait_until_published
|
|
87
|
+
queue.wait_until_published(queue_wait_timeout)
|
|
88
|
+
queue.save_worker_seed(@worker_id, seed)
|
|
66
89
|
|
|
67
90
|
loop do
|
|
68
91
|
# we have to bootstrap this so that it can be used in the first call
|
|
69
92
|
# to `requeue_lost_job` inside the work loop
|
|
70
93
|
update_heartbeat
|
|
71
94
|
|
|
95
|
+
return if queue.build_failed_fast?
|
|
96
|
+
|
|
72
97
|
lost = queue.requeue_lost_job
|
|
73
98
|
puts "Requeued lost job: #{lost}" if lost
|
|
74
99
|
|
|
@@ -88,9 +113,9 @@ module RSpecQ
|
|
|
88
113
|
|
|
89
114
|
# reconfigure rspec
|
|
90
115
|
RSpec.configuration.detail_color = :magenta
|
|
91
|
-
RSpec.configuration.seed =
|
|
92
|
-
RSpec.configuration.backtrace_formatter.filter_gem(
|
|
93
|
-
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues))
|
|
116
|
+
RSpec.configuration.seed = seed
|
|
117
|
+
RSpec.configuration.backtrace_formatter.filter_gem("rspecq")
|
|
118
|
+
RSpec.configuration.add_formatter(Formatters::FailureRecorder.new(queue, job, max_requeues, @worker_id))
|
|
94
119
|
RSpec.configuration.add_formatter(Formatters::ExampleCountRecorder.new(queue))
|
|
95
120
|
RSpec.configuration.add_formatter(Formatters::WorkerHeartbeatRecorder.new(self))
|
|
96
121
|
|
|
@@ -116,12 +141,21 @@ module RSpecQ
|
|
|
116
141
|
def try_publish_queue!(queue)
|
|
117
142
|
return if !queue.become_master
|
|
118
143
|
|
|
144
|
+
if reproduction
|
|
145
|
+
q_size = queue.publish(files_or_dirs_to_run, fail_fast)
|
|
146
|
+
log_event(
|
|
147
|
+
"Reproduction mode. Published queue as given (size=#{q_size})",
|
|
148
|
+
"info"
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
|
|
119
153
|
RSpec.configuration.files_or_directories_to_run = files_or_dirs_to_run
|
|
120
154
|
files_to_run = RSpec.configuration.files_to_run.map { |j| relative_path(j) }
|
|
121
155
|
|
|
122
156
|
timings = queue.timings
|
|
123
157
|
if timings.empty?
|
|
124
|
-
q_size = queue.publish(files_to_run.shuffle)
|
|
158
|
+
q_size = queue.publish(files_to_run.shuffle, fail_fast)
|
|
125
159
|
log_event(
|
|
126
160
|
"No timings found! Published queue in random order (size=#{q_size})",
|
|
127
161
|
"warning"
|
|
@@ -146,7 +180,7 @@ module RSpecQ
|
|
|
146
180
|
jobs.concat(files_to_run)
|
|
147
181
|
end
|
|
148
182
|
|
|
149
|
-
default_timing = timings.values[timings.values.size/2]
|
|
183
|
+
default_timing = timings.values[timings.values.size / 2]
|
|
150
184
|
|
|
151
185
|
# assign timings (based on previous runs) to all jobs
|
|
152
186
|
jobs = jobs.each_with_object({}) do |j, h|
|
|
@@ -160,7 +194,7 @@ module RSpecQ
|
|
|
160
194
|
# sort jobs based on their timings (slowest to be processed first)
|
|
161
195
|
jobs = jobs.sort_by { |_j, t| -t }.map(&:first)
|
|
162
196
|
|
|
163
|
-
puts "Published queue (size=#{queue.publish(jobs)})"
|
|
197
|
+
puts "Published queue (size=#{queue.publish(jobs, fail_fast)})"
|
|
164
198
|
end
|
|
165
199
|
|
|
166
200
|
private
|
|
@@ -171,7 +205,8 @@ module RSpecQ
|
|
|
171
205
|
# see https://github.com/rspec/rspec-core/pull/2723
|
|
172
206
|
if Gem::Version.new(RSpec::Core::Version::STRING) <= Gem::Version.new("3.9.1")
|
|
173
207
|
RSpec.world.instance_variable_set(
|
|
174
|
-
:@example_group_counts_by_spec_file, Hash.new(0)
|
|
208
|
+
:@example_group_counts_by_spec_file, Hash.new(0)
|
|
209
|
+
)
|
|
175
210
|
end
|
|
176
211
|
|
|
177
212
|
# RSpec.clear_examples does not reset those, which causes issues when
|
|
@@ -195,17 +230,17 @@ module RSpecQ
|
|
|
195
230
|
|
|
196
231
|
if !cmd_result.success?
|
|
197
232
|
rspec_output = begin
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
JSON.parse(out)
|
|
234
|
+
rescue JSON::ParserError
|
|
235
|
+
out
|
|
236
|
+
end
|
|
202
237
|
|
|
203
238
|
log_event(
|
|
204
239
|
"Failed to split slow files, falling back to regular scheduling.\n #{err}",
|
|
205
240
|
"error",
|
|
206
241
|
rspec_stdout: rspec_output,
|
|
207
242
|
rspec_stderr: err,
|
|
208
|
-
cmd_result: cmd_result.inspect
|
|
243
|
+
cmd_result: cmd_result.inspect
|
|
209
244
|
)
|
|
210
245
|
|
|
211
246
|
pp rspec_output
|
|
@@ -227,7 +262,7 @@ module RSpecQ
|
|
|
227
262
|
|
|
228
263
|
# Prints msg to standard output and emits an event to Sentry, if the
|
|
229
264
|
# SENTRY_DSN environment variable is set.
|
|
230
|
-
def log_event(msg, level, additional={})
|
|
265
|
+
def log_event(msg, level, additional = {})
|
|
231
266
|
puts msg
|
|
232
267
|
|
|
233
268
|
Raven.capture_message(msg, level: level, extra: {
|
|
@@ -238,8 +273,8 @@ module RSpecQ
|
|
|
238
273
|
populate_timings: populate_timings,
|
|
239
274
|
file_split_threshold: file_split_threshold,
|
|
240
275
|
heartbeat_updated_at: @heartbeat_updated_at,
|
|
241
|
-
object:
|
|
242
|
-
pid: Process.pid
|
|
276
|
+
object: inspect,
|
|
277
|
+
pid: Process.pid
|
|
243
278
|
}.merge(additional))
|
|
244
279
|
end
|
|
245
280
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rspecq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Agis Anastasopoulos
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2021-04-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec-core
|
|
@@ -53,7 +53,7 @@ dependencies:
|
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '0'
|
|
55
55
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
56
|
+
name: minitest
|
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
59
|
- - ">="
|
|
@@ -81,7 +81,7 @@ dependencies:
|
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '0'
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
|
-
name:
|
|
84
|
+
name: rake
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
86
86
|
requirements:
|
|
87
87
|
- - ">="
|
|
@@ -108,6 +108,20 @@ dependencies:
|
|
|
108
108
|
- - ">="
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
110
|
version: '0'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: rubocop
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: 0.93.0
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: 0.93.0
|
|
111
125
|
description:
|
|
112
126
|
email: agis.anast@gmail.com
|
|
113
127
|
executables:
|