megatest 0.1.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Megatest
4
+ class Backtrace
5
+ module InternalFilter
6
+ class << self
7
+ INTERNAL_PATHS = [
8
+ File.expand_path("../assertions.rb:", __FILE__).freeze,
9
+ File.expand_path("../runtime.rb:", __FILE__).freeze,
10
+ ].freeze
11
+
12
+ def call(backtrace)
13
+ backtrace.reject do |frame|
14
+ frame.start_with?(*INTERNAL_PATHS)
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ module RelativePathCleaner
21
+ class << self
22
+ def call(path)
23
+ Megatest.relative_path(path)
24
+ end
25
+ end
26
+ end
27
+
28
+ attr_accessor :filters, :formatters
29
+
30
+ def initialize
31
+ @filters = [InternalFilter]
32
+ @formatters = [RelativePathCleaner]
33
+ @full = false
34
+ end
35
+
36
+ def full!
37
+ @full = true
38
+ end
39
+
40
+ def clean(backtrace)
41
+ return backtrace if @full
42
+
43
+ format(filter(backtrace))
44
+ end
45
+
46
+ def filter(backtrace)
47
+ return backtrace if @full
48
+
49
+ if backtrace
50
+ filters.each do |filter|
51
+ backtrace = filter.call(backtrace)
52
+ end
53
+ backtrace
54
+ else
55
+ []
56
+ end
57
+ end
58
+
59
+ def format(backtrace)
60
+ return backtrace if @full
61
+
62
+ backtrace.map do |frame|
63
+ formatters.each do |formatter|
64
+ frame = formatter.call(frame)
65
+ end
66
+ frame
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ require "optparse"
6
+
7
+ module Megatest
8
+ class CLI
9
+ InvalidArgument = Class.new(ArgumentError)
10
+
11
+ class << self
12
+ def run!
13
+ program_name = $PROGRAM_NAME
14
+ program_name = "megatest" if program_name == `command -v megatest`.strip
15
+ exit(new(program_name, $stdout, $stderr, ARGV, ENV).run)
16
+ end
17
+ end
18
+
19
+ undef_method :puts, :print # Should only use @out.puts or @err.puts
20
+
21
+ RUNNERS = {
22
+ "report" => :report,
23
+ "run" => :run,
24
+ }.freeze
25
+
26
+ def initialize(program_name, out, err, argv, env)
27
+ @out = out
28
+ @err = err
29
+ @argv = argv.dup
30
+ @processes = nil
31
+ @config = Config.new(env)
32
+ @program_name = @config.program_name = program_name
33
+ @runner = nil
34
+ @verbose = false
35
+ @junit = false
36
+ end
37
+
38
+ def run
39
+ configure
40
+ case @runner
41
+ when :report
42
+ report
43
+ when nil, :run
44
+ run_tests
45
+ else
46
+ raise InvalidArgument, "Parsing failure"
47
+ end
48
+ rescue InvalidArgument, OptionParser::ParseError => error
49
+ if error.is_a?(InvalidArgument)
50
+ @err.puts "invalid arguments: #{error.message}"
51
+ else
52
+ @err.puts error.message
53
+ end
54
+ @err.puts
55
+ @err.puts @parser
56
+ 1
57
+ end
58
+
59
+ def configure
60
+ if @runner = RUNNERS[@argv.first]
61
+ @argv.shift
62
+ end
63
+
64
+ Megatest.config = @config
65
+ @parser = build_parser(@runner)
66
+ @parser.parse!(@argv)
67
+ @argv.shift if @argv.first == "--"
68
+ @config
69
+ end
70
+
71
+ def run_tests
72
+ queue = @config.build_queue
73
+
74
+ if queue.distributed?
75
+ raise InvalidArgument, "Distributed queues require a build-id" unless @config.build_id
76
+ raise InvalidArgument, "Distributed queues require a worker-id" unless @config.worker_id
77
+ elsif queue.sharded?
78
+ unless @config.valid_worker_index?
79
+ raise InvalidArgument, "Splitting the queue requires a worker-id lower than workers-count, got: #{@config.worker_id.inspect}"
80
+ end
81
+ end
82
+
83
+ @config.selectors = Selector.parse(@argv)
84
+ Megatest.load_config(@config)
85
+ Megatest.init(@config)
86
+ test_cases = Megatest.load_tests(@config)
87
+
88
+ if test_cases.empty?
89
+ @err.puts "No tests to run"
90
+ return 1
91
+ end
92
+
93
+ queue.populate(test_cases)
94
+ executor.run(queue, default_reporters)
95
+ queue.success? ? 0 : 1
96
+ end
97
+
98
+ def report
99
+ queue = @config.build_queue
100
+
101
+ raise InvalidArgument, "Only distributed queues can be summarized" unless queue.distributed?
102
+ raise InvalidArgument, "Distributed queues require a build-id" unless @config.build_id
103
+ raise InvalidArgument, @argv.join(" ") unless @argv.empty?
104
+
105
+ Megatest.load_config(@argv)
106
+
107
+ QueueReporter.new(@config, queue, @out).run(default_reporters) ? 0 : 1
108
+ end
109
+
110
+ private
111
+
112
+ def default_reporters
113
+ reporters = if @verbose || @config.ci
114
+ [
115
+ Reporters::VerboseReporter.new(@config, @out),
116
+ ]
117
+ else
118
+ [
119
+ Reporters::SimpleReporter.new(@config, @out),
120
+ ]
121
+ end
122
+
123
+ if @config.ci
124
+ reporters << Reporters::OrderReporter.new(@config, open_file("log/test_order.log"))
125
+ end
126
+
127
+ if @junit != false
128
+ junit_file = open_file(@junit || "log/junit.xml")
129
+ reporters << Reporters::JUnitReporter.new(@config, Megatest::Output.new(junit_file, colors: true))
130
+ end
131
+
132
+ reporters
133
+ end
134
+
135
+ def open_file(path)
136
+ File.open(path, "w+")
137
+ rescue Errno::ENOENT
138
+ mkdir_p(File.dirname(path))
139
+ retry
140
+ end
141
+
142
+ def mkdir_p(directory)
143
+ raise InvalidArgument if directory.empty?
144
+
145
+ Dir.mkdir(directory)
146
+ rescue Errno::ENOENT
147
+ mkdir_p(File.dirname(directory))
148
+ retry
149
+ rescue InvalidArgument
150
+ raise InvalidArgument, "Couldn't create directory: #{directory}"
151
+ end
152
+
153
+ def executor
154
+ if @config.jobs_count > 1
155
+ require "megatest/multi_process"
156
+ MultiProcess::Executor.new(@config, @out)
157
+ else
158
+ Executor.new(@config, @out)
159
+ end
160
+ end
161
+
162
+ def build_parser(runner)
163
+ runner = :run if runner.nil?
164
+ OptionParser.new do |opts|
165
+ case runner
166
+ when :report
167
+ opts.banner = "Usage: #{@program_name} report [options]"
168
+ when :run
169
+ opts.banner = "Usage: #{@program_name} run [options] [files or directories]"
170
+ else
171
+ opts.banner = "Usage: #{@program_name} command [options] [files or directories]"
172
+ opts.separator ""
173
+ opts.separator "Commands:"
174
+ opts.separator ""
175
+
176
+ opts.separator "\trun\t\tExecute the given tests."
177
+ opts.separator "\t\t\t $ #{@program_name} test/integration/"
178
+ opts.separator "\t\t\t $ #{@program_name} test/my_test.rb:42 test/another_test.rb:36"
179
+ opts.separator ""
180
+
181
+ opts.separator "\treport\t\tWait for the queue to be entirely processed and report the status"
182
+ opts.separator "\t\t\t $ #{@program_name} report --queue redis://ci-queue.example.com --build-id $CI_BUILD_ID"
183
+ opts.separator ""
184
+ end
185
+
186
+ opts.separator ""
187
+ opts.separator "Options:"
188
+ opts.separator ""
189
+
190
+ opts.on("-b", "--backtrace", "Print full backtraces") do
191
+ @config.backtrace.full!
192
+ end
193
+
194
+ opts.on("-v", "--verbose", "Use the verbose reporter") do
195
+ @verbose = true
196
+ end
197
+
198
+ opts.on("--junit [PATH]", String, "Generate a junit.xml file") do |path|
199
+ @junit = path
200
+ end
201
+
202
+ if runner == :run
203
+ opts.on("--seed SEED", Integer, "The seed used to define run order") do |seed|
204
+ @config.seed = seed
205
+ end
206
+
207
+ opts.on("-j", "--jobs JOBS", Integer, "Number of processes to use") do |jobs|
208
+ @config.jobs_count = jobs
209
+ end
210
+
211
+ help = "Number of consecutive failures before exiting. Default to 1"
212
+ opts.on("-f", "--fail-fast [COUNT]", Integer, help) do |max|
213
+ @config.max_consecutive_failures = (max || 1)
214
+ end
215
+
216
+ opts.on("--max-retries COUNT", Integer, "How many times a given test may be retried") do |max_retries|
217
+ @config.max_retries = max_retries
218
+ end
219
+
220
+ opts.on("--retry-tolerance RATE", Float, "The proportion of tests that may be retried. e.g. 0.05 for 5% of retried tests") do |retry_tolerance|
221
+ @config.retry_tolerance = retry_tolerance
222
+ end
223
+ end
224
+
225
+ opts.separator ""
226
+ opts.separator "Test distribution and sharding:"
227
+ opts.separator ""
228
+
229
+ opts.on("--queue URL", String, "URL of queue server to use for test distribution. Default to $MEGATEST_QUEUE_URL") do |queue_url|
230
+ @config.queue_url = queue_url
231
+ end
232
+
233
+ opts.on("--build-id ID", String, "Unique identifier for the CI build") do |build_id|
234
+ @config.build_id = build_id
235
+ end
236
+
237
+ if runner == :run
238
+ opts.on("--worker-id ID", String, "Unique identifier for the CI job") do |worker_id|
239
+ @config.worker_id = worker_id
240
+ end
241
+
242
+ opts.on("--workers-count COUNT", Integer, "Number of CI jobs") do |workers_count|
243
+ @config.workers_count = workers_count
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ module Compat
7
+ unless Enumerable.method_defined?(:filter_map) # RUBY_VERSION >= "2.7"
8
+ module FilterMap
9
+ refine Enumerable do
10
+ def filter_map(&block)
11
+ result = map(&block)
12
+ result.compact!
13
+ result
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ unless Symbol.method_defined?(:start_with?) # RUBY_VERSION >= "2.7"
20
+ module StartWith
21
+ refine Symbol do
22
+ def start_with?(*args)
23
+ to_s.start_with?(*args)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ unless UnboundMethod.method_defined?(:bind_call) # RUBY_VERSION >= "2.7"
30
+ module BindCall
31
+ refine UnboundMethod do
32
+ def bind_call(receiver, *args, &block)
33
+ bind(receiver).call(*args, &block)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ unless Enumerable.method_defined?(:tally) # RUBY_VERSION >= "2.7"
40
+ module Tally
41
+ refine Enumerable do
42
+ def tally(hash = {})
43
+ each do |element|
44
+ hash[element] = (hash[element] || 0) + 1
45
+ end
46
+ hash
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ unless Symbol.method_defined?(:name) # RUBY_VERSION >= "3.0"
53
+ module Name
54
+ refine Symbol do
55
+ alias_method :name, :to_s
56
+ end
57
+ end
58
+ end
59
+
60
+ unless String.method_defined?(:byterindex) # RUBY_VERSION >= "3.2"
61
+ module ByteRIndex
62
+ refine String do
63
+ def byterindex(matcher, offset = -1)
64
+ if encoding == Encoding::BINARY
65
+ rindex(matcher, offset)
66
+ else
67
+ b.rindex(matcher, offset)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ class CircuitBreaker
7
+ def initialize(max)
8
+ @max = max
9
+ @consecutive_failures = 0
10
+ end
11
+
12
+ def record_result(result)
13
+ if result.bad?
14
+ @consecutive_failures += 1
15
+ elsif result.success?
16
+ @consecutive_failures = 0
17
+ end
18
+ end
19
+
20
+ def break?
21
+ @consecutive_failures >= @max
22
+ end
23
+ end
24
+
25
+ class CIService
26
+ @implementations = []
27
+
28
+ class << self
29
+ def inherited(base)
30
+ super
31
+ @implementations << base
32
+ end
33
+
34
+ def configure(config, env)
35
+ @implementations.each do |service|
36
+ service.new(env).configure(config)
37
+ end
38
+ end
39
+ end
40
+
41
+ attr_reader :env
42
+
43
+ def initialize(env)
44
+ @env = env
45
+ end
46
+
47
+ def configure(_config)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ class CircleCI < self
52
+ def configure(config)
53
+ if env["CIRCLE_BUILD_URL"]
54
+ config.ci = true
55
+ config.build_id = env["CIRCLE_BUILD_URL"]
56
+ config.worker_id = env["CIRCLE_NODE_INDEX"]
57
+ config.workers_count = Integer(env["CIRCLE_NODE_TOTAL"])
58
+ config.seed = env["CIRCLE_SHA1"]&.first(4)&.to_i(16)
59
+ end
60
+ end
61
+ end
62
+
63
+ class Buildkite < self
64
+ def configure(config)
65
+ if env["BUILDKITE_BUILD_ID"]
66
+ config.ci = true
67
+ config.build_id = env["BUILDKITE_BUILD_ID"]
68
+ config.worker_id = env["BUILDKITE_PARALLEL_JOB"]
69
+ config.workers_count = env["BUILDKITE_PARALLEL_JOB_COUNT"]
70
+ config.seed = env["BUILDKITE_COMMIT"]&.first(4)&.to_i(16)
71
+ end
72
+ end
73
+ end
74
+
75
+ class Travis < self
76
+ def configure(config)
77
+ if env["TRAVIS_BUILD_ID"]
78
+ config.ci = true
79
+ config.build_id = env["TRAVIS_BUILD_ID"]
80
+ # Travis doesn't have builtin parallelization
81
+ # but CI_NODE_INDEX is what is used in their documentation
82
+ # https://docs.travis-ci.com/user/speeding-up-the-build#parallelizing-rspec-cucumber-and-minitest-on-multiple-vms
83
+ config.worker_id = env["CI_NODE_INDEX"]
84
+ config.workers_count = env["CI_NODE_TOTAL"]
85
+ config.seed = env["TRAVIS_COMMIT"]&.first(4)&.to_i(16)
86
+ end
87
+ end
88
+ end
89
+
90
+ class Heroku < self
91
+ def configure(config)
92
+ if env["HEROKU_TEST_RUN_ID"]
93
+ config.ci = true
94
+ config.build_id = env["HEROKU_TEST_RUN_ID"]
95
+ config.worker_id = env["CI_NODE_INDEX"]
96
+ config.workers_count = env["CI_NODE_TOTAL"]
97
+ config.seed = env["HEROKU_TEST_RUN_COMMIT_VERSION"]&.first(4)&.to_i(16)
98
+ end
99
+ end
100
+ end
101
+
102
+ class Megatest < self
103
+ def configure(config)
104
+ if env["CI"]
105
+ config.ci = true
106
+ end
107
+
108
+ if url = env["MEGATEST_QUEUE_URL"]
109
+ config.queue_url = url
110
+ end
111
+
112
+ if id = env["MEGATEST_BUILD_ID"]
113
+ config.build_id = id
114
+ end
115
+
116
+ if id = env["MEGATEST_WORKER_ID"]
117
+ config.worker_id = id
118
+ end
119
+
120
+ if seed = env["SEED"]
121
+ config.seed = seed
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ # :startdoc:
128
+
129
+ class << self
130
+ attr_writer :config
131
+
132
+ def config
133
+ yield @config if block_given?
134
+ @config
135
+ end
136
+ end
137
+
138
+ class Config
139
+ attr_accessor :queue_url, :retry_tolerance, :max_retries, :jobs_count, :job_index, :load_paths, :deprecations,
140
+ :build_id, :heartbeat_frequency, :minitest_compatibility, :ci, :selectors
141
+ attr_reader :before_fork_callbacks, :global_setup_callbacks, :worker_setup_callbacks, :backtrace, :circuit_breaker, :seed,
142
+ :worker_id, :workers_count
143
+ attr_writer :differ, :pretty_printer, :program_name, :colors
144
+
145
+ def initialize(env)
146
+ @load_paths = ["test"] # For easier transition from other frameworks
147
+ @retry_tolerance = 0.0
148
+ @max_retries = 0
149
+ @deprecations = true
150
+ @full_backtrace = false
151
+ @queue_url = nil
152
+ @ci = false
153
+ @build_id = nil
154
+ @worker_id = nil
155
+ @workers_count = 1
156
+ @jobs_count = 1
157
+ @colors = nil # auto
158
+ @before_fork_callbacks = []
159
+ @global_setup_callbacks = []
160
+ @job_setup_callbacks = []
161
+ @heartbeat_frequency = 5
162
+ @backtrace = Backtrace.new
163
+ @program_name = nil
164
+ @circuit_breaker = CircuitBreaker.new(Float::INFINITY)
165
+ @seed = Random.rand(0xFFFF)
166
+ @differ = Differ.new(self)
167
+ @pretty_printer = PrettyPrint.new(self)
168
+ @minitest_compatibility = false
169
+ @selectors = nil
170
+ CIService.configure(self, env)
171
+ end
172
+
173
+ def program_name
174
+ @program_name || "megatest"
175
+ end
176
+
177
+ def worker_id=(id)
178
+ @worker_id = if id.is_a?(String) && /\A\d+\z/.match?(id)
179
+ Integer(id)
180
+ else
181
+ id
182
+ end
183
+ end
184
+
185
+ def workers_count=(count)
186
+ @workers_count = count ? Integer(count) : 1
187
+ end
188
+
189
+ def valid_worker_index?
190
+ worker_id.is_a?(Integer) && worker_id.positive? && worker_id < workers_count
191
+ end
192
+
193
+ def colors(io = nil)
194
+ case @colors
195
+ when true
196
+ Output::ANSIColors
197
+ when false
198
+ Output::NoColors
199
+ else
200
+ if io && !io.tty?
201
+ Output::NoColors
202
+ else
203
+ Output::ANSIColors
204
+ end
205
+ end
206
+ end
207
+
208
+ def max_consecutive_failures=(max)
209
+ @circuit_breaker = CircuitBreaker.new(max)
210
+ end
211
+
212
+ def diff(expected, actual)
213
+ @differ&.call(expected, actual)
214
+ end
215
+
216
+ def pretty_print(object)
217
+ @pretty_printer.pretty_print(object)
218
+ end
219
+ alias_method :pp, :pretty_print
220
+
221
+ # We always return a new generator with the same seed as to
222
+ # best reproduce remote builds locally if the same seed is given.
223
+ def random
224
+ Random.new(@seed)
225
+ end
226
+
227
+ def seed=(seed)
228
+ @seed = Integer(seed)
229
+ end
230
+
231
+ def build_queue
232
+ case @queue_url
233
+ when nil
234
+ Queue.build(self)
235
+ when /\Arediss?:/
236
+ require "megatest/redis_queue"
237
+ RedisQueue.build(self)
238
+ else
239
+ raise ArgumentError, "Unsupported queue type: #{@queue_url.inspect}"
240
+ end
241
+ end
242
+
243
+ def run_before_fork_callback
244
+ @before_fork_callback.each { |c| c.call(self) }
245
+ end
246
+
247
+ def before_fork(&block)
248
+ @before_fork_callbacks << block
249
+ end
250
+
251
+ def run_global_setup_callbacks
252
+ @global_setup_callbacks.each { |c| c.call(self) }
253
+ end
254
+
255
+ def global_setup(&block)
256
+ @global_setup_callbacks << block
257
+ end
258
+
259
+ def run_job_setup_callbacks(job_index)
260
+ @job_setup_callbacks.each { |c| c.call(self, job_index) }
261
+ end
262
+
263
+ def job_setup(&block)
264
+ @job_setup_callbacks << block
265
+ end
266
+
267
+ def retries?
268
+ @max_retries.positive?
269
+ end
270
+
271
+ def total_max_retries(size)
272
+ if @retry_tolerance.positive?
273
+ (size * @retry_tolerance).ceil
274
+ else
275
+ @max_retries * size
276
+ end
277
+ end
278
+ end
279
+
280
+ @config = Config.new({})
281
+ end