turbo_tests2 3.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.
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "parallel_tests/rspec/runner"
5
+
6
+ require_relative "../utils/hash_extension"
7
+
8
+ module TurboTests
9
+ class Runner
10
+ using CoreExtensions
11
+
12
+ class << self
13
+ def create(count)
14
+ # We are unable to load parallel tests' tasks in the normal way (top of file)
15
+ # because it requires that the Rails.application instance already be configured
16
+ require "parallel_tests/tasks"
17
+
18
+ ENV["PARALLEL_TEST_FIRST_IS_1"] = "true"
19
+ command = ["bundle", "exec", "rake", "db:create", "RAILS_ENV=#{ParallelTests::Tasks.rails_env}"]
20
+ args = {count: count.to_s}
21
+ ParallelTests::Tasks.run_in_parallel(command, args)
22
+ end
23
+
24
+ def run(opts = {})
25
+ files = opts[:files]
26
+ formatters = opts[:formatters]
27
+ tags = opts[:tags]
28
+ parallel_options = opts[:parallel_options] || {}
29
+
30
+ start_time = opts.fetch(:start_time) { RSpec::Core::Time.now }
31
+ runtime_log = opts.fetch(:runtime_log, nil)
32
+ verbose = opts.fetch(:verbose, false)
33
+ fail_fast = opts.fetch(:fail_fast, nil)
34
+ count = opts.fetch(:count, nil)
35
+ seed = opts.fetch(:seed, nil)
36
+ seed_used = !seed.nil?
37
+ print_failed_group = opts.fetch(:print_failed_group, false)
38
+ nice = opts.fetch(:nice, false)
39
+
40
+ use_runtime_info = files == ["spec"]
41
+
42
+ if use_runtime_info
43
+ parallel_options[:runtime_log] = runtime_log
44
+ else
45
+ parallel_options[:group_by] = :filesize
46
+ end
47
+
48
+ warn("VERBOSE") if verbose
49
+
50
+ reporter = Reporter.from_config(formatters, start_time, seed, seed_used, files, parallel_options)
51
+
52
+ new(
53
+ reporter: reporter,
54
+ formatters: formatters,
55
+ start_time: start_time,
56
+ files: files,
57
+ tags: tags,
58
+ runtime_log: runtime_log,
59
+ verbose: verbose,
60
+ fail_fast: fail_fast,
61
+ count: count,
62
+ seed: seed,
63
+ seed_used: seed_used,
64
+ print_failed_group: print_failed_group,
65
+ use_runtime_info: use_runtime_info,
66
+ parallel_options: parallel_options,
67
+ nice: nice,
68
+ ).run
69
+ end
70
+ end
71
+
72
+ def initialize(**opts)
73
+ @formatters = opts[:formatters]
74
+ @reporter = opts[:reporter]
75
+ @files = opts[:files]
76
+ @tags = opts[:tags]
77
+ @verbose = opts[:verbose]
78
+ @fail_fast = opts[:fail_fast]
79
+ @start_time = opts[:start_time]
80
+ @count = opts[:count]
81
+ @seed = opts[:seed]
82
+ @seed_used = opts[:seed_used]
83
+ @nice = opts[:nice]
84
+ @use_runtime_info = opts[:use_runtime_info]
85
+
86
+ @load_time = 0
87
+ @load_count = 0
88
+ @failure_count = 0
89
+
90
+ # Supports runtime_log as a top level option,
91
+ # but also nested inside parallel_options
92
+ @runtime_log = opts[:runtime_log] || "tmp/turbo_rspec_runtime.log"
93
+ @parallel_options = opts.fetch(:parallel_options, {})
94
+ @parallel_options[:runtime_log] ||= @runtime_log
95
+ @record_runtime = @parallel_options[:group_by] == :runtime
96
+
97
+ @messages = Thread::Queue.new
98
+ @threads = []
99
+ @wait_threads = []
100
+ @error = false
101
+ @print_failed_group = opts[:print_failed_group]
102
+ end
103
+
104
+ def run
105
+ @num_processes = [
106
+ ParallelTests.determine_number_of_processes(@count),
107
+ ParallelTests::RSpec::Runner.tests_with_size(@files, {}).size,
108
+ ].min
109
+
110
+ tests_in_groups =
111
+ ParallelTests::RSpec::Runner.tests_in_groups(
112
+ @files,
113
+ @num_processes,
114
+ **@parallel_options,
115
+ )
116
+
117
+ subprocess_opts = {
118
+ record_runtime: @record_runtime,
119
+ }
120
+
121
+ @reporter.report(tests_in_groups) do |_reporter|
122
+ old_signal = Signal.trap(:INT) { handle_interrupt }
123
+
124
+ @wait_threads = tests_in_groups.map.with_index do |tests, process_id|
125
+ start_regular_subprocess(tests, process_id + 1, **subprocess_opts)
126
+ end.compact
127
+ @interrupt_handled = false
128
+
129
+ handle_messages
130
+
131
+ @threads.each(&:join)
132
+
133
+ report_failed_group(tests_in_groups) if @print_failed_group
134
+
135
+ Signal.trap(:INT, old_signal)
136
+
137
+ if @reporter.failed_examples.empty? && @wait_threads.map(&:value).all?(&:success?)
138
+ 0
139
+ else
140
+ # From https://github.com/galtzo-floss/turbo_tests2/pull/20/
141
+ @wait_threads.map { |thread| thread.value.exitstatus }.max
142
+ end
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def handle_interrupt
149
+ if @interrupt_handled
150
+ Kernel.exit
151
+ else
152
+ puts "\nShutting down subprocesses..."
153
+ @wait_threads.each do |wait_thr|
154
+ begin
155
+ child_pid = wait_thr.pid
156
+ pgid = Process.respond_to?(:getpgid) ? Process.getpgid(child_pid) : 0
157
+ Process.kill(:INT, child_pid) if Process.pid != pgid
158
+ rescue Errno::ESRCH, Errno::ENOENT
159
+ # process already gone — ignore
160
+ end
161
+ end
162
+ @interrupt_handled = true
163
+ end
164
+ end
165
+
166
+ def start_regular_subprocess(tests, process_id, **opts)
167
+ start_subprocess(
168
+ {"TEST_ENV_NUMBER" => process_id.to_s},
169
+ @tags.map { |tag| "--tag=#{tag}" },
170
+ tests,
171
+ process_id,
172
+ **opts,
173
+ )
174
+ end
175
+
176
+ def start_subprocess(env, extra_args, tests, process_id, record_runtime:)
177
+ if tests.empty?
178
+ @messages << {
179
+ type: "exit",
180
+ process_id: process_id,
181
+ }
182
+
183
+ nil
184
+ else
185
+ env["RSPEC_FORMATTER_OUTPUT_ID"] = SecureRandom.uuid
186
+ env["RUBYOPT"] = ["-I#{File.expand_path("..", __dir__)}", ENV["RUBYOPT"]].compact.join(" ")
187
+ env["RSPEC_SILENCE_FILTER_ANNOUNCEMENTS"] = "1"
188
+
189
+ command_name =
190
+ if ENV["RSPEC_EXECUTABLE"]
191
+ ENV["RSPEC_EXECUTABLE"].split
192
+ elsif ENV["BUNDLE_BIN_PATH"]
193
+ [ENV["BUNDLE_BIN_PATH"], "exec", "rspec"]
194
+ else
195
+ "rspec"
196
+ end
197
+
198
+ record_runtime_options =
199
+ if record_runtime
200
+ [
201
+ "--format",
202
+ "ParallelTests::RSpec::RuntimeLogger",
203
+ "--out",
204
+ @runtime_log,
205
+ ]
206
+ else
207
+ []
208
+ end
209
+
210
+ seed_option = if @seed_used
211
+ [
212
+ "--seed", @seed,
213
+ ]
214
+ else
215
+ []
216
+ end
217
+
218
+ spec_opts = ParallelTests::RSpec::Runner.send(:spec_opts)
219
+
220
+ command = [
221
+ *command_name,
222
+ *extra_args,
223
+ *seed_option,
224
+ "--format",
225
+ "TurboTests::JsonRowsFormatter",
226
+ *record_runtime_options,
227
+ *spec_opts,
228
+ *tests,
229
+ ]
230
+ command.unshift("nice") if @nice
231
+
232
+ if @verbose
233
+ command_str = [
234
+ env.map { |k, v| "#{k}=#{v}" }.join(" "),
235
+ command.join(" "),
236
+ ].select { |x| x.size > 0 }.join(" ")
237
+
238
+ warn("Process #{process_id}: #{command_str}")
239
+ end
240
+
241
+ stdin, stdout, stderr, wait_thr = Open3.popen3(env, *command)
242
+ stdin.close
243
+
244
+ # rubocop:disable ThreadSafety/NewThread
245
+ @threads <<
246
+ Thread.new do
247
+ stdout.each_line do |line|
248
+ result = line.split(env["RSPEC_FORMATTER_OUTPUT_ID"])
249
+
250
+ initial = result.shift
251
+ print(initial) unless initial.empty?
252
+
253
+ message = result.shift
254
+ next unless message
255
+
256
+ message = JSON.parse(message, symbolize_names: true)
257
+
258
+ message[:process_id] = process_id
259
+ @messages << message
260
+ end
261
+
262
+ @messages << {type: "exit", process_id: process_id}
263
+ end
264
+ # rubocop:enable ThreadSafety/NewThread
265
+
266
+ @threads << start_copy_thread(stderr, $stderr)
267
+
268
+ # rubocop:disable ThreadSafety/NewThread
269
+ @threads << Thread.new do
270
+ @messages << {type: "error"} unless wait_thr.value.success?
271
+ end
272
+ # rubocop:enable ThreadSafety/NewThread
273
+
274
+ wait_thr
275
+ end
276
+ end
277
+
278
+ def start_copy_thread(src, dst)
279
+ # rubocop:disable ThreadSafety/NewThread
280
+ Thread.new do
281
+ # rubocop:enable ThreadSafety/NewThread
282
+ loop do
283
+ begin
284
+ msg = src.readpartial(4096)
285
+ rescue EOFError
286
+ src.close
287
+ break
288
+ else
289
+ dst.write(msg)
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ def handle_messages
296
+ exited = 0
297
+
298
+ loop do
299
+ message = @messages.pop
300
+ case message[:type]
301
+ when "example_passed"
302
+ example = FakeExample.from_obj(message[:example])
303
+ @reporter.example_passed(example)
304
+ when "group_started"
305
+ @reporter.group_started(message[:group].to_struct)
306
+ when "group_finished"
307
+ @reporter.group_finished
308
+ when "example_pending"
309
+ example = FakeExample.from_obj(message[:example])
310
+ @reporter.example_pending(example)
311
+ when "load_summary"
312
+ message = message[:summary]
313
+ # NOTE: notifications order and content is not guaranteed hence the fetch
314
+ # and count increment tracking to get the latest accumulated load time
315
+ @reporter.load_time = message[:load_time] if message.fetch(:count, 0) > @load_count
316
+ when "example_failed"
317
+ example = FakeExample.from_obj(message[:example])
318
+ @reporter.example_failed(example)
319
+ @failure_count += 1
320
+ if fail_fast_met
321
+ @threads.each(&:kill)
322
+ break
323
+ end
324
+ when "message"
325
+ if message[:message].include?("An error occurred") || message[:message].include?("occurred outside of examples")
326
+ @reporter.error_outside_of_examples(message[:message])
327
+ @error = true
328
+ else
329
+ @reporter.message(message[:message])
330
+ end
331
+ when "seed"
332
+ when "close"
333
+ when "error"
334
+ # Do nothing
335
+ nil
336
+ when "exit"
337
+ exited += 1
338
+ break if exited == @num_processes
339
+ else
340
+ warn("Unhandled message in main process: #{message}")
341
+ end
342
+
343
+ $stdout.flush
344
+ end
345
+ rescue Interrupt
346
+ end
347
+
348
+ def fail_fast_met
349
+ !@fail_fast.nil? && @failure_count >= @fail_fast
350
+ end
351
+
352
+ def report_failed_group(tests_in_groups)
353
+ @wait_threads.map(&:value).each_with_index do |value, index|
354
+ next if value.success?
355
+
356
+ failing_group = tests_in_groups[index].join(" ")
357
+ puts "Group that failed: #{failing_group}"
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboTests
4
+ class Shim
5
+ Result = Struct.new(:status, :path, :message, :exit_code, keyword_init: true)
6
+
7
+ DEFAULT_RELATIVE_PATH = File.join("bin", "turbo_tests")
8
+ MANAGED_MARKER = "Generated by turbo_tests2 shim install"
9
+
10
+ class << self
11
+ def install(project_root: Dir.pwd, path: nil)
12
+ new(project_root: project_root, path: path).install
13
+ end
14
+
15
+ def remove(project_root: Dir.pwd, path: nil)
16
+ new(project_root: project_root, path: path).remove
17
+ end
18
+ end
19
+
20
+ attr_reader :project_root, :path
21
+
22
+ def initialize(project_root: Dir.pwd, path: nil)
23
+ @project_root = File.expand_path(project_root)
24
+ @path = File.expand_path(path || DEFAULT_RELATIVE_PATH, @project_root)
25
+ end
26
+
27
+ def install
28
+ existing_managed = false
29
+ if File.exist?(path)
30
+ existing = File.read(path)
31
+ return result(:unchanged, "Shim already installed at #{display_path}.", 0) if existing == rendered_content
32
+ return result(:conflict, "Refusing to overwrite unmanaged file at #{display_path}.", 1) unless managed_content?(existing)
33
+ existing_managed = true
34
+ end
35
+
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ File.write(path, rendered_content)
38
+ FileUtils.chmod(0o755, path)
39
+
40
+ result(existing_managed ? :updated : :installed, "Installed turbo_tests shim at #{display_path}.", 0)
41
+ end
42
+
43
+ def remove
44
+ return result(:missing, "No shim found at #{display_path}.", 0) unless File.exist?(path)
45
+
46
+ content = File.read(path)
47
+ return result(:conflict, "Refusing to remove unmanaged file at #{display_path}.", 1) unless managed_content?(content)
48
+
49
+ File.delete(path)
50
+ result(:removed, "Removed turbo_tests shim at #{display_path}.", 0)
51
+ end
52
+
53
+ private
54
+
55
+ def display_path
56
+ path.sub(%r{\A#{Regexp.escape(project_root)}/?}, "")
57
+ end
58
+
59
+ def managed_content?(content)
60
+ content.include?(MANAGED_MARKER)
61
+ end
62
+
63
+ def rendered_content
64
+ <<~SH
65
+ #!/usr/bin/env sh
66
+ # #{MANAGED_MARKER}
67
+ exec bundle exec turbo_tests2 "$@"
68
+ SH
69
+ end
70
+
71
+ def result(status, message, exit_code)
72
+ Result.new(status: status, path: path, message: message, exit_code: exit_code)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboTests
4
+ module Version
5
+ VERSION = "3.0.0"
6
+ end
7
+ VERSION = Version::VERSION # Traditional Constant Location
8
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "version_gem"
4
+ require_relative "turbo_tests/version"
5
+
6
+ TurboTests::Version.class_eval do
7
+ extend VersionGem::Basic
8
+ end
9
+ require "securerandom"
10
+ require "open3"
11
+ require "fileutils"
12
+ require "json"
13
+
14
+ require "rspec"
15
+
16
+ require "parallel_tests"
17
+ require "parallel_tests/rspec/runner"
18
+
19
+ require "turbo_tests/reporter"
20
+ require "turbo_tests/runner"
21
+ require "turbo_tests/json_rows_formatter"
22
+
23
+ module TurboTests
24
+ autoload :CLI, "turbo_tests/cli"
25
+ autoload :Shim, "turbo_tests/shim"
26
+ FakeException = Struct.new(:backtrace, :message, :cause)
27
+ class FakeException
28
+ class << self
29
+ def from_obj(obj)
30
+ return unless obj
31
+
32
+ klass =
33
+ Class.new(FakeException) do
34
+ define_singleton_method(:name) do
35
+ obj[:class_name]
36
+ end
37
+ end
38
+
39
+ klass.new(
40
+ obj[:backtrace],
41
+ obj[:message],
42
+ FakeException.from_obj(obj[:cause]),
43
+ )
44
+ end
45
+ end
46
+ end
47
+
48
+ FakeExecutionResult = Struct.new(
49
+ :example_skipped?,
50
+ :pending_message,
51
+ :status,
52
+ :pending_fixed?,
53
+ :exception,
54
+ :pending_exception,
55
+ )
56
+ class FakeExecutionResult
57
+ class << self
58
+ def from_obj(obj)
59
+ new(
60
+ obj[:example_skipped?],
61
+ obj[:pending_message],
62
+ obj[:status].to_sym,
63
+ obj[:pending_fixed?],
64
+ FakeException.from_obj(obj[:exception]),
65
+ FakeException.from_obj(obj[:exception]),
66
+ )
67
+ end
68
+ end
69
+ end
70
+
71
+ FakeExample = Struct.new(
72
+ :execution_result,
73
+ :location,
74
+ :description,
75
+ :full_description,
76
+ :metadata,
77
+ :location_rerun_argument,
78
+ )
79
+ class FakeExample
80
+ class << self
81
+ def from_obj(obj)
82
+ metadata = obj[:metadata]
83
+
84
+ metadata[:shared_group_inclusion_backtrace].map! do |frame|
85
+ RSpec::Core::SharedExampleGroupInclusionStackFrame.new(
86
+ frame[:shared_group_name],
87
+ frame[:inclusion_location],
88
+ )
89
+ end
90
+
91
+ metadata[:shared_group_inclusion_backtrace] = metadata.delete(:shared_group_inclusion_backtrace)
92
+
93
+ new(
94
+ FakeExecutionResult.from_obj(obj[:execution_result]),
95
+ obj[:location],
96
+ obj[:description],
97
+ obj[:full_description],
98
+ metadata,
99
+ obj[:location_rerun_argument],
100
+ )
101
+ end
102
+ end
103
+
104
+ def notification
105
+ RSpec::Core::Notifications::ExampleNotification.for(
106
+ self,
107
+ )
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "rspec/core"
5
+
6
+ RSpec.shared_context("with simplecov spawn coverage") do
7
+ let(:simplecov_spawn_path) do
8
+ File.expand_path(".simplecov_spawn.rb", Bundler.root.to_s)
9
+ end
10
+
11
+ around do |example|
12
+ original_rubyopt = ENV.fetch("RUBYOPT", nil)
13
+ begin
14
+ if defined?(SimpleCov) && SimpleCov.running
15
+ spawn_path = simplecov_spawn_path
16
+ raise ArgumentError, "Expected SimpleCov spawn shim at #{spawn_path}" unless File.file?(spawn_path)
17
+
18
+ ENV["RUBYOPT"] = ["-r#{spawn_path}", original_rubyopt].compact.join(" ").strip
19
+ end
20
+
21
+ example.run
22
+ ensure
23
+ ENV["RUBYOPT"] = original_rubyopt
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,6 @@
1
+ # For technical reasons, if we move to Zeitwerk, this cannot be require_relative.
2
+ # See: https://github.com/fxn/zeitwerk#for_gem_extension
3
+ # But we will use require_relative to avoid the risk of it loading the old turbo_tests gem.
4
+ # Bottom-line: Do not use Zeitwerk in this gem.
5
+ # Hook for other libraries to load this library (e.g. via bundler)
6
+ require_relative "turbo_tests"
@@ -0,0 +1,7 @@
1
+ module CoreExtensions
2
+ refine Hash do
3
+ def to_struct
4
+ Struct.new(*keys).new(*values.map { |value| value.is_a?(Hash) ? value.to_struct : value })
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module TurboTests
2
+ module Version
3
+ VERSION: String
4
+ end
5
+ VERSION: String
6
+ end
7
+
data.tar.gz.sig ADDED
@@ -0,0 +1 @@
1
+ Yv)� w?Z�Ď_G�]��z�J�����