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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ class Differ
7
+ HEADER = "--- expected\n+++ actual\n\n"
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ using Compat::ByteRIndex unless String.method_defined?(:byterindex)
14
+
15
+ def call(expected, actual)
16
+ if String === expected && String === actual
17
+ if multiline?(expected) || multiline?(actual)
18
+ multiline_string_diff(expected, actual)
19
+ else
20
+ single_line_string_diff(expected, actual)
21
+ end
22
+ elsif Array === expected && Array === actual
23
+ array_diff(expected, actual)
24
+ elsif Hash === expected && Hash === actual
25
+ hash_diff(expected, actual)
26
+ else
27
+ expected_inspect = pp(expected)
28
+ actual_inspect = pp(actual)
29
+
30
+ if multiline?(expected_inspect) || multiline?(actual_inspect)
31
+ object_diff(expected, expected_inspect, actual_inspect)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def pp(object)
39
+ @config.pretty_print(object)
40
+ end
41
+
42
+ def object_diff(expected, expected_inspect, actual_inspect)
43
+ differ = PatienceDiff::Differ.new(@config.colors)
44
+ diff = differ.diff_text(expected_inspect, actual_inspect)
45
+ render_diff(expected, diff)
46
+ end
47
+
48
+ def multiline_string_diff(expected, actual)
49
+ differ = PatienceDiff::Differ.new(@config.colors)
50
+
51
+ if expected.encoding != actual.encoding
52
+ expected = encoding_prefix(expected) << expected
53
+ actual = encoding_prefix(actual) << actual
54
+ end
55
+
56
+ if need_escape?(expected) || need_escape?(actual)
57
+ expected = escape_string(expected)
58
+ actual = escape_string(actual)
59
+ end
60
+
61
+ if expected.end_with?("\n") ^ actual.end_with?("\n")
62
+ expected = "#{expected}\n\\ No newline at end of string" unless expected.end_with?("\n")
63
+ actual = "#{actual}\n\\ No newline at end of string" unless actual.end_with?("\n")
64
+ end
65
+ diff = differ.diff_text(expected, actual)
66
+ render_diff(expected, diff)
67
+ end
68
+
69
+ def multiline?(string)
70
+ string.byterindex("\n", -1)
71
+ end
72
+
73
+ def render_diff(expected, diff)
74
+ if diff
75
+ "#{HEADER}#{diff.join}"
76
+ else
77
+ <<~TEXT
78
+ No visible difference in the #{expected.class}#inspect output.
79
+ You should look at the implementation of #== on #{expected.class.name} or its members.
80
+ #{pp(expected)}
81
+ TEXT
82
+ end
83
+ end
84
+
85
+ def encoding_prefix(string)
86
+ encoding_name = string.encoding == Encoding::BINARY ? "BINARY" : string.encoding.name
87
+ prefix = +"# encoding: #{encoding_name}\n"
88
+ prefix.force_encoding(string.encoding)
89
+ end
90
+
91
+ def escape_string(string)
92
+ string.b.split("\n").map { |line| line.inspect.byteslice(1..-2) }.join("\n")
93
+ end
94
+
95
+ def need_escape?(string)
96
+ (string.encoding == Encoding::BINARY && !string.ascii_only?) || !string.valid_encoding?
97
+ end
98
+
99
+ def single_line_string_diff(expected, actual)
100
+ "Expected: #{pp(expected)}\n Actual: #{pp(actual)}"
101
+ end
102
+
103
+ def array_diff(expected, actual)
104
+ differ = PatienceDiff::Differ.new(@config.colors)
105
+ diff = differ.diff_sequences(array_sequence(expected), array_sequence(actual))
106
+ render_diff(expected, diff)
107
+ end
108
+
109
+ def array_sequence(array)
110
+ array = array.map { |e| " ".dup << pp(e).chomp << ",\n" }
111
+ array.unshift("[\n")
112
+ array << "]\n"
113
+ end
114
+
115
+ def hash_diff(expected, actual)
116
+ differ = PatienceDiff::Differ.new(@config.colors)
117
+ expected_seq, actual_seq = hash_sort(expected, actual)
118
+ diff = differ.diff_sequences(hash_sequence(expected_seq), hash_sequence(actual_seq))
119
+ render_diff(expected, diff)
120
+ end
121
+
122
+ def hash_sort(expected, actual)
123
+ [expected.sort, actual.sort]
124
+ rescue ArgumentError
125
+ [expected, actual]
126
+ end
127
+
128
+ def hash_sequence(pairs)
129
+ pairs = pairs.map do |k, v|
130
+ " ".dup << pp(k) << " => " << pp(v).chomp << ",\n"
131
+ end
132
+ pairs.unshift("{\n")
133
+ pairs << "}\n"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Megatest
4
+ # All the methods necessary to define test cases.
5
+ # Can be used directly to define test cases in modules
6
+ # for later inclusion.
7
+ #
8
+ # Example:
9
+ #
10
+ # module SomeSharedTests
11
+ # extend Megatest::DSL
12
+ #
13
+ # setup do
14
+ # end
15
+ #
16
+ # test "the truth" do
17
+ # assert_equal 4, 2 + 2
18
+ # end
19
+ # end
20
+ #
21
+ # class SomeTest < Megatest::Test
22
+ # include SomeSharedTests
23
+ # end
24
+ #
25
+ # class SomeOtherTest < Megatest::Test
26
+ # include SomeSharedTests
27
+ # end
28
+ module DSL
29
+ # :stopdoc:
30
+ class << self
31
+ def extended(mod)
32
+ super
33
+ if mod.is_a?(Class)
34
+ unless mod == ::Megatest::Test
35
+ raise ArgumentError, "Megatest::DSL should only be extended in modules"
36
+ end
37
+ else
38
+ ::Megatest.registry.shared_suite(mod)
39
+ end
40
+ end
41
+ end
42
+
43
+ using Compat::StartWith unless Symbol.method_defined?(:start_with?)
44
+
45
+ def method_added(name)
46
+ super
47
+ if name.start_with?("test_")
48
+ ::Megatest.registry.suite(self).register_test_case(name, instance_method(name), nil)
49
+ end
50
+ end
51
+
52
+ # :startdoc:
53
+
54
+ ##
55
+ # Define a test case.
56
+ #
57
+ # Example:
58
+ #
59
+ # test "the truth" do
60
+ # assert_equal 4, 2 + 2
61
+ # end
62
+ #
63
+ # For ease of transition from other test frameworks, any method
64
+ # that starts by +test_+ is also considered a test:
65
+ #
66
+ # Example:
67
+ #
68
+ # def test_the_truth
69
+ # assert_equal 4, 2 + 2
70
+ # end
71
+ def test(name, tags = nil, &block)
72
+ ::Megatest.registry.suite(self).register_test_case(-name, block, tags)
73
+ end
74
+
75
+ # Applies tags to all the test case of this suite
76
+ #
77
+ # Example:
78
+ #
79
+ # class SomeTest < Megatest::Test
80
+ # tag focus: true
81
+ #
82
+ # test "something" do
83
+ # assert_equal true, __test__.tag(:focus)
84
+ # end
85
+ #
86
+ # test "something else", focus: false do
87
+ # assert_equal false, __test__.tag(:focus)
88
+ # end
89
+ # end
90
+ def tag(**kwargs)
91
+ ::Megatest.registry.suite(self).add_tags(kwargs)
92
+ end
93
+
94
+ ##
95
+ # Creates a context block, for logically grouping test cases.
96
+ # The context string will be prepended to all the test cases
97
+ # defined within the block.
98
+ #
99
+ # Example:
100
+ #
101
+ # context "maths" do
102
+ # test "the truth" do
103
+ # assert_equal 4, 2 + 2
104
+ # end
105
+ #
106
+ # test "oddity" do
107
+ # refute_predicate 4, odd?
108
+ # end
109
+ # end
110
+ #
111
+ # Setup and teardown callbacks are not allowed withing a context blocks,
112
+ # as it too easily lead to "write only" tests. It's only meant to help
113
+ # group test cases together.
114
+ #
115
+ # If you need a common setup procedure, just define a helper method, and explictly call it.
116
+ #
117
+ # Example:
118
+ #
119
+ # context "admin user" do
120
+ # def setup_admin_user
121
+ # # ...
122
+ # end
123
+ #
124
+ # test "#admin?" do
125
+ # user = setup_admin_user
126
+ # assert_predicate user, :admin?
127
+ # end
128
+ #
129
+ # test "#can?(:delete_post)" do
130
+ # user = setup_admin_user
131
+ # assert user.can?(:delete_post)
132
+ # end
133
+ # end
134
+ def context(name, tags = nil, &block)
135
+ ::Megatest.registry.suite(self).with_context(name, tags, &block)
136
+ end
137
+
138
+ # Registers a block to be invoked before every test cases.
139
+ def setup(&block)
140
+ ::Megatest.registry.suite(self).on_setup(block)
141
+ end
142
+
143
+ # Registers a block to be invoked around every test cases.
144
+ # The block will recieve a Proc as first argument and MUST
145
+ # call it.
146
+ #
147
+ # Example:
148
+ #
149
+ # around do |block|
150
+ # do_something do
151
+ # block.call
152
+ # end
153
+ # end
154
+ def around(&block)
155
+ ::Megatest.registry.suite(self).on_around(block)
156
+ end
157
+
158
+ # Registers a block to be invoked after every test cases,
159
+ # regardless of whether it passed or failed.
160
+ def teardown(&block)
161
+ ::Megatest.registry.suite(self).on_teardown(block)
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ class Executor
7
+ class ExternalMonitor
8
+ def initialize(config)
9
+ require "rbconfig"
10
+ @config = config
11
+ spawn
12
+ end
13
+
14
+ def reap
15
+ if @pipe
16
+ @pipe.close
17
+ @pipe = nil
18
+ _, status = Process.waitpid2(@pid)
19
+ @pid = nil
20
+ status
21
+ end
22
+ end
23
+
24
+ def spawn
25
+ child_read, @pipe = IO.pipe
26
+ ready_pipe, child_write = IO.pipe
27
+ @pid = Process.spawn(
28
+ RbConfig.ruby,
29
+ File.expand_path("../queue_monitor.rb", __FILE__),
30
+ in: child_read,
31
+ out: child_write,
32
+ )
33
+ child_read.close
34
+ Marshal.dump(@config, @pipe)
35
+
36
+ # Check the process is alive.
37
+ if ready_pipe.wait_readable(10)
38
+ ready_pipe.gets
39
+ ready_pipe.close
40
+ Process.kill(0, @pid)
41
+ else
42
+ Process.kill(0, @pid)
43
+ Process.wait(@pid)
44
+ raise Error, "ExternalMonitor failed to boot"
45
+ end
46
+ end
47
+ end
48
+
49
+ attr_reader :wall_time
50
+
51
+ def initialize(config, out)
52
+ @config = config
53
+ @out = Output.new(out, colors: @config.colors)
54
+ end
55
+
56
+ def run(queue, reporters)
57
+ start_time = Megatest.now
58
+
59
+ @config.run_global_setup_callbacks
60
+ @config.run_job_setup_callbacks(nil)
61
+
62
+ monitor = ExternalMonitor.new(@config) if queue.respond_to?(:heartbeat)
63
+
64
+ reporters.each { |r| r.start(self, queue) }
65
+
66
+ runner = Runner.new(@config)
67
+
68
+ begin
69
+ while true
70
+ if test_case = queue.pop_test
71
+ reporters.each { |r| r.before_test_case(queue, test_case) }
72
+
73
+ result = runner.execute(test_case)
74
+
75
+ result = queue.record_result(result)
76
+ reporters.each { |r| r.after_test_case(queue, test_case, result) }
77
+
78
+ @config.circuit_breaker.record_result(result)
79
+ break if @config.circuit_breaker.break?
80
+ elsif queue.empty?
81
+ break
82
+ else
83
+ # There was no tests to pop, but not all tests are completed.
84
+ # So we stick around to pop tests that could be lost.
85
+ sleep(1)
86
+ end
87
+ end
88
+ rescue Interrupt
89
+ # Early exit
90
+ end
91
+
92
+ monitor&.reap
93
+
94
+ @wall_time = Megatest.now - start_time
95
+ reporters.each { |r| r.summary(self, queue, queue.summary) }
96
+
97
+ if @config.circuit_breaker.break?
98
+ @out.error("Exited early because too many failures were encountered")
99
+ end
100
+
101
+ queue.cleanup
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "socket"
5
+
6
+ # :stopdoc:
7
+
8
+ module Megatest
9
+ # Fairly experimental multi-process queue implementation.
10
+ # It's absolutely not resilient yet, if something goes a bit wrong
11
+ # in may fail in unexpected ways (e.g. hang or whatever).
12
+ # At this stage it's only here to uncover what in the design
13
+ # need to be refactored to make multi-processing and test
14
+ # distribution work well (See the TODOs).
15
+ module MultiProcess
16
+ class << self
17
+ def socketpair
18
+ UNIXSocket.socketpair(:SOCK_STREAM).map { |s| MessageSocket.new(s) }
19
+ end
20
+ end
21
+
22
+ class MessageSocket
23
+ def initialize(socket)
24
+ @socket = socket
25
+ end
26
+
27
+ def <<(message)
28
+ begin
29
+ @socket.write(Marshal.dump(message))
30
+ rescue Errno::EPIPE
31
+ return nil # Other side was closed
32
+ end
33
+ self
34
+ end
35
+
36
+ def read
37
+ Marshal.load(@socket)
38
+ rescue EOFError
39
+ nil # Other side was closed
40
+ end
41
+
42
+ def closed?
43
+ @socket.closed?
44
+ end
45
+
46
+ def close
47
+ @socket.close
48
+ end
49
+
50
+ def to_io
51
+ @socket
52
+ end
53
+ end
54
+
55
+ class ClientQueue
56
+ def initialize(socket, test_cases_index)
57
+ @socket = socket
58
+ @test_cases_index = test_cases_index
59
+ end
60
+
61
+ def pop_test
62
+ @socket << [:pop]
63
+ if test_id = @socket.read
64
+ @test_cases_index.fetch(test_id)
65
+ end
66
+ end
67
+
68
+ def record_result(result)
69
+ @socket << [:record, result]
70
+ @socket.read
71
+ end
72
+
73
+ def close
74
+ @socket.close
75
+ end
76
+
77
+ def to_io
78
+ @socket.to_io
79
+ end
80
+ end
81
+
82
+ class Job
83
+ def initialize(config, index)
84
+ @config = config
85
+ @index = index
86
+ @pid = nil
87
+ @child_socket, @parent_socket = MultiProcess.socketpair
88
+ @assigned_test = nil
89
+ @idle = false
90
+ end
91
+
92
+ def run(executor, parent_queue)
93
+ @pid = Process.fork do
94
+ @config.job_index = @index
95
+ @parent_socket.close
96
+ executor.after_fork_in_child(self)
97
+
98
+ queue = ClientQueue.new(@child_socket, parent_queue)
99
+ @config.run_job_setup_callbacks(@index)
100
+
101
+ runner = Runner.new(@config)
102
+
103
+ begin
104
+ while (test_case = queue.pop_test)
105
+ result = runner.execute(test_case)
106
+ result = queue.record_result(result)
107
+ @config.circuit_breaker.record_result(result)
108
+ break if @config.circuit_breaker.break?
109
+ end
110
+ rescue Interrupt
111
+ end
112
+ queue.close
113
+
114
+ # We don't want to run at_exit hooks the app may have
115
+ # installed.
116
+ Process.exit!(0)
117
+ end
118
+ @child_socket.close
119
+ end
120
+
121
+ def to_io
122
+ @parent_socket.to_io
123
+ end
124
+
125
+ def term
126
+ Process.kill(:TERM, @pid)
127
+ rescue Errno::ESRCH
128
+ # Already dead
129
+ end
130
+
131
+ def close
132
+ @parent_socket.close
133
+ @child_socket.close
134
+ end
135
+
136
+ def closed?
137
+ @parent_socket.closed?
138
+ end
139
+
140
+ def idle?
141
+ @idle
142
+ end
143
+
144
+ def process(queue, reporters)
145
+ if @idle
146
+ if @assigned_test = queue.pop_test
147
+ @idle = false
148
+ @parent_socket << @assigned_test&.id
149
+ end
150
+ return
151
+ end
152
+
153
+ message, *args = @parent_socket.read
154
+ case message
155
+ when nil
156
+ # Socket closed, child probably died
157
+ @parent_socket.close
158
+ when :pop
159
+ if @assigned_test = queue.pop_test
160
+ @parent_socket << @assigned_test&.id
161
+ else
162
+ @idle = true
163
+ end
164
+ when :record
165
+ result = queue.record_result(*args)
166
+ @assigned_test = nil
167
+ @parent_socket << result
168
+ reporters.each { |r| r.after_test_case(queue, nil, result) }
169
+ @config.circuit_breaker.record_result(result)
170
+ else
171
+ raise "Unexpected message: #{message.inspect}"
172
+ end
173
+ end
174
+
175
+ def on_exit(queue, reporters)
176
+ if @assigned_test
177
+ result = queue.record_lost_test(@assigned_test)
178
+ @assigned_test = nil
179
+ reporters.each { |r| r.after_test_case(queue, nil, result) }
180
+ end
181
+ end
182
+
183
+ def reap
184
+ Process.wait(@pid)
185
+ end
186
+ end
187
+
188
+ class InlineMonitor
189
+ def initialize(config, queue)
190
+ @config = config
191
+ @queue = queue
192
+ @last_heartbeat = 0
193
+ end
194
+
195
+ def tick
196
+ now = Megatest.now
197
+ if now - @last_heartbeat > @config.heartbeat_frequency && @queue.heartbeat
198
+ @last_heartbeat = now
199
+ end
200
+ end
201
+ end
202
+
203
+ class Executor
204
+ attr_reader :wall_time
205
+
206
+ def initialize(config, out)
207
+ @config = config
208
+ @out = Output.new(out, colors: config.colors)
209
+ end
210
+
211
+ def after_fork_in_child(active_job)
212
+ @jobs.each do |job|
213
+ job.close unless job == active_job
214
+ end
215
+ end
216
+
217
+ def run(queue, reporters)
218
+ start_time = Megatest.now
219
+ @config.run_global_setup_callbacks
220
+ @jobs = @config.jobs_count.times.map { |index| Job.new(@config, index) }
221
+
222
+ @config.before_fork_callbacks.each(&:call)
223
+ @jobs.each { |j| j.run(self, queue.test_cases_index) }
224
+
225
+ monitor = InlineMonitor.new(@config, queue) if queue.respond_to?(:heartbeat)
226
+
227
+ begin
228
+ while true
229
+ monitor&.tick
230
+ dead_jobs = @jobs.select(&:closed?).each { |j| j.on_exit(queue, reporters) }
231
+ @jobs -= dead_jobs
232
+ break if @jobs.empty?
233
+ break if queue.empty?
234
+
235
+ @jobs.select(&:idle?).each do |job|
236
+ job.process(queue, reporters)
237
+ end
238
+
239
+ reads, = IO.select(@jobs, nil, nil, 1)
240
+ reads&.each do |job|
241
+ job.process(queue, reporters)
242
+ end
243
+
244
+ break if @config.circuit_breaker.break?
245
+ end
246
+ rescue Interrupt
247
+ @jobs.each(&:term) # Early exit
248
+ end
249
+
250
+ @jobs.each(&:close)
251
+ @jobs.each(&:reap)
252
+ @wall_time = Megatest.now - start_time
253
+ reporters.each { |r| r.summary(self, queue, queue.summary) }
254
+
255
+ if @config.circuit_breaker.break?
256
+ @out.error("Exited early because too many failures were encountered")
257
+ end
258
+
259
+ queue.cleanup
260
+ end
261
+ end
262
+ end
263
+ end