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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ class Runner
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def execute(test_case)
12
+ if test_case.tag(:isolated)
13
+ isolate(test_case) do
14
+ run(test_case)
15
+ end
16
+ else
17
+ run(test_case)
18
+ end
19
+ end
20
+
21
+ if Megatest.fork?
22
+ def isolate(test_case)
23
+ read, write = IO.pipe.each(&:binmode)
24
+ pid = Process.fork do
25
+ read.close
26
+ result = yield
27
+ Marshal.dump(result, write)
28
+ write.close
29
+ # We don't want to run at_exit hooks the app may have
30
+ # installed.
31
+ Process.exit!(0)
32
+ end
33
+ write.close
34
+ result = begin
35
+ Marshal.load(read)
36
+ rescue EOFError
37
+ TestCaseResult.new(test_case).lost
38
+ end
39
+ Process.wait(pid)
40
+ result
41
+ end
42
+ else
43
+ def isolate(test_case)
44
+ parent_read, child_write = IO.pipe.each(&:binmode)
45
+ child_read, parent_write = IO.pipe.each(&:binmode)
46
+ pid = Subprocess.spawn(child_read, child_write, "run_test")
47
+ child_read.close
48
+ child_write.close
49
+
50
+ Marshal.dump(@config, parent_write)
51
+ Marshal.dump(test_case.source_file, parent_write)
52
+ Marshal.dump(test_case.id, parent_write)
53
+ parent_write.close
54
+
55
+ result = begin
56
+ Marshal.load(parent_read)
57
+ rescue EOFError
58
+ TestCaseResult.new(test_case).lost
59
+ end
60
+ Process.wait(pid)
61
+ result
62
+ end
63
+ end
64
+
65
+ def run(test_case)
66
+ result = TestCaseResult.new(test_case)
67
+ runtime = Runtime.new(@config, test_case, result)
68
+ instance = test_case.klass.new(runtime)
69
+
70
+ # We always reset the seed before running any test as to have the most consistent
71
+ # result as possible, especially on retries.
72
+ Random.srand(@config.seed)
73
+
74
+ result.record_time do
75
+ ran = false
76
+ failed = false
77
+ recursive_callbacks(test_case.around_callbacks) do
78
+ ran = true
79
+ return result if runtime.record_failures { instance.before_setup }
80
+
81
+ test_case.each_setup_callback do |callback|
82
+ failed ||= runtime.record_failures(downlevel: 2) { instance.instance_exec(&callback) }
83
+ end
84
+ failed ||= runtime.record_failures { instance.setup }
85
+ failed ||= runtime.record_failures { instance.after_setup }
86
+
87
+ failed ||= test_case.execute(runtime, instance)
88
+
89
+ result.ensure_assertions unless @config.minitest_compatibility
90
+ ensure
91
+ runtime.record_failures do
92
+ instance.before_teardown
93
+ end
94
+ test_case.each_teardown_callback do |callback|
95
+ runtime.record_failures(downlevel: 2) do
96
+ instance.instance_exec(&callback)
97
+ end
98
+ end
99
+ runtime.record_failures do
100
+ instance.teardown
101
+ end
102
+ runtime.record_failures do
103
+ instance.after_teardown
104
+ end
105
+ end
106
+
107
+ result.did_not_run("Test wasn't run. Did an around block failed to yield?") unless ran
108
+ end
109
+ end
110
+
111
+ def recursive_callbacks(callbacks, &block)
112
+ if callback = callbacks.pop
113
+ callback.call(-> { recursive_callbacks(callbacks, &block) })
114
+ else
115
+ yield
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ class Runtime
7
+ attr_reader :test_case, :result
8
+
9
+ def initialize(config, test_case, result)
10
+ @config = config
11
+ @test_case = test_case
12
+ @result = result
13
+ @asserting = false
14
+ end
15
+
16
+ support_locations = begin
17
+ error = StandardError.new
18
+ # Ruby 3.4: https://github.com/ruby/ruby/pull/10017
19
+ error.set_backtrace(caller_locations(1, 1))
20
+ true
21
+ rescue TypeError
22
+ false
23
+ end
24
+
25
+ if support_locations
26
+ def assert(uplevel: 1)
27
+ if @asserting
28
+ yield
29
+ else
30
+ @asserting = true
31
+ @result.assertions_count += 1
32
+ begin
33
+ yield
34
+ rescue Assertion => failure
35
+ if failure.backtrace.empty?
36
+ failure.set_backtrace(caller_locations(uplevel + 2))
37
+ end
38
+ raise
39
+ ensure
40
+ @asserting = false
41
+ end
42
+ end
43
+ end
44
+
45
+ def strip_backtrace(error, yield_file, yield_line, downlevel)
46
+ if backtrace = error.backtrace_locations
47
+ rindex = backtrace.rindex { |l| l.lineno == yield_line && l.path == yield_file }
48
+ backtrace = backtrace.slice(0..rindex)
49
+ backtrace.pop(downlevel) unless downlevel.zero?
50
+ error.set_backtrace(backtrace)
51
+ elsif backtrace = error.backtrace
52
+ yield_point = "#{yield_file}:#{yield_line}:"
53
+ rindex = backtrace.rindex { |l| l.start_with?(yield_point) }
54
+ backtrace = backtrace.slice(0..rindex)
55
+ backtrace.pop(downlevel) unless downlevel.zero?
56
+ error.set_backtrace(backtrace)
57
+ end
58
+
59
+ error
60
+ end
61
+ else
62
+ def assert(uplevel: 1)
63
+ if @asserting
64
+ yield
65
+ else
66
+ @asserting = true
67
+ @result.assertions_count += 1
68
+ begin
69
+ yield
70
+ rescue Assertion => failure
71
+ if failure.backtrace.empty?
72
+ failure.set_backtrace(caller(uplevel + 2))
73
+ end
74
+ raise
75
+ ensure
76
+ @asserting = false
77
+ end
78
+ end
79
+ end
80
+
81
+ def strip_backtrace(error, yield_file, yield_line, downlevel)
82
+ if backtrace = error.backtrace
83
+ yield_point = "#{yield_file}:#{yield_line}:"
84
+ rindex = backtrace.rindex { |l| l.start_with?(yield_point) }
85
+ backtrace = backtrace.slice(0..rindex)
86
+ backtrace.pop(downlevel) unless downlevel.zero?
87
+ error.set_backtrace(backtrace)
88
+ end
89
+
90
+ error
91
+ end
92
+ end
93
+
94
+ def msg(positional, keyword)
95
+ if positional.nil?
96
+ keyword
97
+ elsif !keyword.nil?
98
+ raise ArgumentError, "Can't pass both a positional and keyword assertion message"
99
+ else
100
+ positional # TODO: deprecation mecanism
101
+ end
102
+ end
103
+
104
+ def expect_no_failures
105
+ was_asserting = @asserting
106
+ @asserting = false
107
+ yield
108
+ rescue Assertion, *Megatest::IGNORED_ERRORS
109
+ raise # Exceptions we shouldn't rescue
110
+ rescue Exception => unexpected_error
111
+ raise UnexpectedError, unexpected_error, EMPTY_BACKTRACE
112
+ ensure
113
+ @asserting = was_asserting
114
+ end
115
+
116
+ EMPTY_BACKTRACE = [].freeze
117
+
118
+ def fail(user_message, *message)
119
+ message = build_message(message)
120
+ if user_message
121
+ user_message = user_message.call if user_message.respond_to?(:call)
122
+ user_message = String(user_message)
123
+ if message && !user_message.end_with?("\n")
124
+ user_message += "\n"
125
+ end
126
+ message = "#{user_message}#{message}"
127
+ end
128
+ raise(Assertion, message, EMPTY_BACKTRACE)
129
+ end
130
+
131
+ def build_message(strings)
132
+ return if strings.empty?
133
+
134
+ if (strings.size + strings.sum(&:size)) < 80
135
+ strings.join(" ")
136
+ else
137
+ strings.join("\n\n")
138
+ end
139
+ end
140
+
141
+ def minitest_compatibility?
142
+ @config.minitest_compatibility
143
+ end
144
+
145
+ def pp(object)
146
+ @config.pretty_print(object)
147
+ end
148
+
149
+ def diff(expected, actual)
150
+ @config.diff(expected, actual)
151
+ end
152
+
153
+ def record_failures(downlevel: 1, &block)
154
+ expect_no_failures(&block)
155
+ rescue Assertion => assertion
156
+ error = assertion
157
+ while error
158
+ error = strip_backtrace(error, __FILE__, __LINE__ - 4, downlevel + 2)
159
+ error = error.cause
160
+ end
161
+
162
+ @result.failures << Failure.new(assertion)
163
+ true
164
+ else
165
+ false
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ module Selector
7
+ class List
8
+ def initialize(loaders, filters)
9
+ @loaders = loaders
10
+ if loaders.empty?
11
+ @loaders = [Loader.new("test")]
12
+ end
13
+ @filters = filters
14
+ end
15
+
16
+ def main_paths
17
+ paths = @loaders.map(&:path)
18
+ paths.compact!
19
+ paths.uniq!
20
+ paths
21
+ end
22
+
23
+ def paths(random:)
24
+ paths = @loaders.reduce([]) do |paths_to_load, loader|
25
+ loader.append_paths(paths_to_load)
26
+ end
27
+
28
+ paths.uniq!
29
+ paths.sort!
30
+ paths.shuffle!(random: random) if random
31
+ paths
32
+ end
33
+
34
+ def select(registry, random:)
35
+ # If any of the selector points to an exact test or a subset of a suite,
36
+ # then each selector is responsible for shuffling the group of tests it selects,
37
+ # so that tests are shuffled inside groups, but groups are ordered.
38
+ test_cases = if @loaders.any?(&:partial?)
39
+ @loaders.reduce([]) do |tests_to_run, loader|
40
+ loader.append_tests(tests_to_run, registry, random: random)
41
+ end
42
+ else
43
+ # Otherwise, we do one big shuffle at the end, all groups are mixed.
44
+ test_cases = registry.test_cases
45
+ test_cases.sort!
46
+ test_cases.shuffle!(random: random) if random
47
+ test_cases
48
+ end
49
+
50
+ @filters.reduce(test_cases) do |cases, filter|
51
+ filter.select(cases)
52
+ end
53
+ end
54
+ end
55
+
56
+ class Loader
57
+ attr_reader :path
58
+
59
+ def initialize(path, filter = nil)
60
+ @path = File.expand_path(path)
61
+ if @directory = File.directory?(@path)
62
+ @path = File.join(@path, "/")
63
+ @paths = Megatest.glob(@path)
64
+ else
65
+ @paths = [@path]
66
+ end
67
+ @filter = filter
68
+ end
69
+
70
+ def partial?
71
+ !!@filter
72
+ end
73
+
74
+ def append_tests(tests_to_run, registry, random:)
75
+ test_cases = select(registry)
76
+ if partial?
77
+ test_cases.sort!
78
+ test_cases.shuffle!(random: random) if random
79
+ end
80
+ tests_to_run.concat(test_cases)
81
+ end
82
+
83
+ def append_paths(paths_to_load)
84
+ paths_to_load.concat(@paths)
85
+ end
86
+
87
+ def select(registry)
88
+ test_cases = if @directory
89
+ registry.test_cases.select do |test_case|
90
+ test_case.source_file.start_with?(@path)
91
+ end
92
+ else
93
+ registry.test_cases_by_path(@path)
94
+ end
95
+
96
+ if @filter
97
+ @filter.select(test_cases)
98
+ else
99
+ test_cases
100
+ end
101
+ end
102
+ end
103
+
104
+ class NegativeLoader
105
+ def initialize(loader)
106
+ @loader = loader
107
+ end
108
+
109
+ def partial?
110
+ @loader.partial?
111
+ end
112
+
113
+ def append_paths(paths_to_load)
114
+ if @loader.partial?
115
+ paths_to_load
116
+ else
117
+ paths_to_not_load = @loader.append_paths([])
118
+ paths_to_load - paths_to_not_load
119
+ end
120
+ end
121
+
122
+ def append_tests(tests_to_run, registry, random:)
123
+ tests_to_not_run = @loader.append_tests([], registry, random: nil)
124
+ tests_to_run - tests_to_not_run
125
+ end
126
+ end
127
+
128
+ class TagFilter
129
+ class << self
130
+ def parse(arg)
131
+ if match = arg.match(/\A@([\w-]+)(?:=(.*))?\z/)
132
+ new(match[1], match[2])
133
+ end
134
+ end
135
+ end
136
+
137
+ def initialize(tag, value)
138
+ @tag = tag.to_sym
139
+ @value = value
140
+ end
141
+
142
+ def select(test_cases)
143
+ if @value
144
+ test_cases.select do |test_case|
145
+ test_case.tag(@tag).to_s == @value
146
+ end
147
+ else
148
+ test_cases.select do |test_case|
149
+ test_case.tag(@tag)
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ class ExactLineFilter
156
+ class << self
157
+ def parse(arg)
158
+ if match = arg.match(/\A(\d+)(?:~(\d+))?\z/)
159
+ new(Integer(match[1]), match[2]&.to_i)
160
+ end
161
+ end
162
+ end
163
+
164
+ def initialize(line, index)
165
+ @line = line
166
+ @index = index
167
+ end
168
+
169
+ def select(test_cases)
170
+ test_cases = test_cases.sort { |a, b| b.source_line <=> a.source_line }
171
+ test_cases = test_cases.drop_while { |t| t.source_line > @line }
172
+
173
+ # Line not found, fallback to run the whole file?
174
+ return [] if test_cases.empty?
175
+
176
+ real_line = test_cases.first&.source_line
177
+ test_cases = test_cases.take_while { |t| t.source_line == real_line }
178
+
179
+ if @index
180
+ test_cases.select! { |t| t.index == @index }
181
+ end
182
+ test_cases
183
+ end
184
+ end
185
+
186
+ class NameMatchFilter
187
+ class << self
188
+ def parse(arg)
189
+ if match = arg.match(%r{\A/(.+)\z})
190
+ new(match[1])
191
+ end
192
+ end
193
+ end
194
+
195
+ def initialize(pattern)
196
+ @pattern = Regexp.new(pattern)
197
+ end
198
+
199
+ def select(test_cases)
200
+ test_cases.select do |t|
201
+ @pattern.match?(t.name) || @pattern.match?(t.id)
202
+ end
203
+ end
204
+ end
205
+
206
+ class NameFilter
207
+ class << self
208
+ def parse(arg)
209
+ if match = arg.match(/\A#(.+)\z/)
210
+ new(match[1])
211
+ end
212
+ end
213
+ end
214
+
215
+ def initialize(name)
216
+ @name = name
217
+ end
218
+
219
+ def select(test_cases)
220
+ test_cases.select do |t|
221
+ @name == t.name || @name == t.id
222
+ end
223
+ end
224
+ end
225
+
226
+ class NegativeFilter
227
+ def initialize(filter)
228
+ @filter = filter
229
+ end
230
+
231
+ def select(test_cases)
232
+ test_cases - @filter.select(test_cases)
233
+ end
234
+ end
235
+
236
+ FILTERS = [
237
+ ExactLineFilter,
238
+ TagFilter,
239
+ NameMatchFilter,
240
+ NameFilter,
241
+ ].freeze
242
+
243
+ class << self
244
+ def parse(argv)
245
+ if argv.empty?
246
+ return List.new([], [])
247
+ end
248
+
249
+ argv = argv.dup
250
+ loaders = []
251
+ filters = []
252
+
253
+ negative = false
254
+
255
+ until argv.empty?
256
+ case argument = argv.shift
257
+ when "!"
258
+ negative = true
259
+ else
260
+ loader_str, filter_str = argument.split(":", 2)
261
+ loader_str = nil if loader_str.empty?
262
+
263
+ filter = nil
264
+ if filter_str
265
+ FILTERS.each do |filter_class|
266
+ if filter = filter_class.parse(filter_str)
267
+ break
268
+ end
269
+ end
270
+ end
271
+
272
+ if loader_str
273
+ loader = Loader.new(loader_str, filter)
274
+ if negative
275
+ loader = NegativeLoader.new(loader)
276
+ negative = false
277
+ end
278
+ loaders << loader
279
+ else
280
+ if negative
281
+ filter = NegativeFilter.new(filter)
282
+ negative = false
283
+ end
284
+ filters << filter
285
+ end
286
+ end
287
+ end
288
+
289
+ List.new(loaders, filters)
290
+ end
291
+ end
292
+ end
293
+ end