ci_runner 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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "json"
6
+ require "open-uri"
7
+
8
+ module CIRunner
9
+ # A simple client to interact the GitHub API.
10
+ #
11
+ # @example Using the client
12
+ # GithubClient.new("access_token").me
13
+ class GithubClient
14
+ Error = Class.new(StandardError)
15
+
16
+ # @return [Net::HTTP] An instance of Net:HTTP configured to make requests to the GitHub API endpoint.
17
+ def self.default_client
18
+ Net::HTTP.new("api.github.com", 443).tap do |http|
19
+ http.use_ssl = true
20
+ http.read_timeout = 3
21
+ http.write_timeout = 3
22
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
23
+ end
24
+ end
25
+
26
+ # @param access_token [String] The access token with "repo" scope.
27
+ # @param client [Net::HTTP]
28
+ def initialize(access_token, client = self.class.default_client)
29
+ @access_token = access_token
30
+ @client = client
31
+ end
32
+
33
+ # Make an API request to get the authenticated user. Used to verify if the access token
34
+ # the user has stored in its config is valid.
35
+ #
36
+ # @return [Hash] See GitHub documentation.
37
+ #
38
+ # @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user
39
+ def me
40
+ get("/user")
41
+ end
42
+
43
+ # Makes an API request to get the CI checks for the +commit+.
44
+ #
45
+ # @param repository [String] The full repository name, including the owner (rails/rails)
46
+ # @param commit [String] The Git commit that has been pushed to GitHub.
47
+ #
48
+ # @return [Hash] See GitHub documentation.
49
+ #
50
+ # @see https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference
51
+ def check_runs(repository, commit)
52
+ get("/repos/#{repository}/commits/#{commit}/check-runs")
53
+ end
54
+
55
+ # Makes two requests to get the CI log for a check run.
56
+ # The first request returns a 302 containing a Location header poiting to a short lived url to download the log.
57
+ # The second request is to actually download the log.
58
+ #
59
+ # @param repository [String] The full repository name, including the owner (rails/rails)
60
+ # @param check_run_id [Integer] The GitHub ID of the check run.
61
+ #
62
+ # @return [Tempfile, IO] Depending on the size of the response. Quirk of URI.open.
63
+ #
64
+ # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
65
+ def download_log(repository, check_run_id)
66
+ download_url = get("/repos/#{repository}/actions/jobs/#{check_run_id}/logs")
67
+
68
+ URI.open(download_url) # rubocop:disable Security/Open
69
+ end
70
+
71
+ private
72
+
73
+ # Perform an authenticated GET request.
74
+ #
75
+ # @param path [String] The resource to access.
76
+ #
77
+ # @return (See #request)
78
+ def get(path)
79
+ request(Net::HTTP::Get, path)
80
+ end
81
+
82
+ # Perform an authenticated request.
83
+ #
84
+ # @param verb_class [Net::HTTPRequest] A subclass of Net::HTTPRequest.
85
+ # @param path [String] The resource to access.
86
+ #
87
+ # @return [Hash, String] A decoded JSON response or a String pointing to the Location redirection.
88
+ def request(verb_class, path)
89
+ req = verb_class.new(path)
90
+ req["Accept"] = "application/vnd.github+json"
91
+ req.basic_auth("user", @access_token)
92
+
93
+ response = @client.request(req)
94
+
95
+ case response.code.to_i
96
+ when 200..204
97
+ response.content_type == "application/json" ? JSON.parse(response.body) : response.body
98
+ when 302
99
+ response["Location"]
100
+ else
101
+ raise(Error, "GitHub response: Status: #{response.code}. Body:\n\n#{response.body}")
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+
7
+ module CIRunner
8
+ # A PORO to help download and cache a GitHub CI log.
9
+ #
10
+ # @example Using the service
11
+ # log_dl = LogDownloader.new("commit_sha", "catanacorp/catana", { "id" => 1, "name" => "Ruby Test 3.1.2" })
12
+ # log_file = log_dl.fetch
13
+ # puts log_file # => File
14
+ #
15
+ # @see https://docs.github.com/en/rest/actions/workflow-jobs#download-job-logs-for-a-workflow-run
16
+ class LogDownloader
17
+ # @param commit [String] A Git commit. Used to compute the file name we are going to cache.
18
+ # @param repository [String] The repository full name, including the owner (i.e. rails/rails).
19
+ # @param check_run [Hash] A GitHub CI check for which we want to download the log.
20
+ def initialize(commit, repository, check_run)
21
+ @commit = commit
22
+ @repository = repository
23
+ @check_run = check_run
24
+ end
25
+
26
+ # Download the CI logs from GitHub or retrieve it from disk in case we previously downloaded it.
27
+ #
28
+ # @param block [Proc, Lambda] A proc that gets called if fetching the logs from GitHub fails. Allows the CLI to
29
+ # prematurely exit while cleaning up the CLI::UI frame.
30
+ #
31
+ # @return [File] A file ready to be read.
32
+ def fetch(&block)
33
+ return cached_log if cached_log
34
+
35
+ github_client = GithubClient.new(Configuration::User.instance.github_token)
36
+ error = nil
37
+
38
+ ::CLI::UI.spinner("Downloading CI logs from GitHub", auto_debrief: false) do
39
+ logfile = github_client.download_log(@repository, @check_run["id"])
40
+
41
+ cache_log(logfile)
42
+ rescue GithubClient::Error => e
43
+ error = e
44
+
45
+ ::CLI::UI::Spinner::TASK_FAILED
46
+ end
47
+
48
+ block.call(error) if error
49
+ cached_log
50
+ end
51
+
52
+ private
53
+
54
+ # Store the log on the user's disk.
55
+ #
56
+ # @param logfile [Tempfile, IO] Depending on the size of the response. A quirk of URI.open.
57
+ #
58
+ # @return [void]
59
+ def cache_log(logfile)
60
+ FileUtils.mkdir_p(computed_file_path.dirname)
61
+
62
+ if logfile.is_a?(Tempfile)
63
+ FileUtils.cp(logfile, computed_file_path)
64
+ else
65
+ File.write(computed_file_path, logfile.read)
66
+ end
67
+ end
68
+
69
+ # @return [String] A path where to store the logfile on the users' disk.
70
+ # The path is composed of the commit, the CI check name and the repository full name.
71
+ #
72
+ # @return [Pathname]
73
+ #
74
+ # @example Given a repository "rails/rails". A CI check called "Ruby 3.0". A commit "abcdef".
75
+ # puts computed_filed_path # ==> /var/tmpdir/T/.../rails/rails/log-abcdef-Ruby 3.0
76
+ def computed_file_path
77
+ normalized_run_name = @check_run["name"].tr("/", "_")
78
+
79
+ log_folder.join("log-#{@commit[0..12]}-#{normalized_run_name}.log")
80
+ end
81
+
82
+ # @return [Pathname]
83
+ def log_folder
84
+ Pathname(Dir.tmpdir).join(@repository)
85
+ end
86
+
87
+ # @return [Pathname, false] Depending if the log has been downloaded before.
88
+ def cached_log
89
+ return false unless computed_file_path.exist?
90
+
91
+ computed_file_path
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "open3"
5
+
6
+ module CIRunner
7
+ module Runners
8
+ class Base
9
+ # @return [Array<TestFailure>]
10
+ attr_accessor :failures
11
+
12
+ # @return (See TestFailure#seed)
13
+ attr_accessor :seed
14
+
15
+ # @return [String] The ruby version detected.
16
+ attr_accessor :ruby_version
17
+
18
+ # @return [String] The Gemfile detected.
19
+ attr_accessor :gemfile
20
+
21
+ # Children needs to implement this method to tell if they recognize the log output and if it can process them.
22
+ #
23
+ # @param _ [String] The CI log output.
24
+ #
25
+ # @return [Boolean]
26
+ def self.match?(_)
27
+ raise NotImplementedError, "Subclass responsability"
28
+ end
29
+
30
+ # @param ci_log [String] The CI log output.
31
+ def initialize(ci_log)
32
+ @ci_log = ci_log
33
+ @failures = []
34
+ @buffer = +""
35
+ end
36
+
37
+ # Parse the CI log. Iterate over each line and try to detect:
38
+ #
39
+ # - The Ruby version
40
+ # - The Gemfile used
41
+ # - Failures (Including their names, their class and the file path)
42
+ #
43
+ # @return [void]
44
+ def parse!
45
+ @ci_log.each_line do |line|
46
+ case line
47
+ when seed_regex
48
+ @seed = first_matching_group(Regexp.last_match)
49
+ when ruby_detection_regex
50
+ @ruby_version = first_matching_group(Regexp.last_match)
51
+
52
+ @buffer << line if buffering?
53
+ when gemfile_detection_regex
54
+ @gemfile = first_matching_group(Regexp.last_match)
55
+ when buffer_detection_regex
56
+ if Configuration::Project.instance.process_on_new_match? && buffering?
57
+ process_buffer
58
+ @buffer.clear
59
+ end
60
+
61
+ @buffer << line
62
+ else
63
+ @buffer << line if buffering?
64
+ end
65
+ end
66
+
67
+ process_buffer if buffering?
68
+ end
69
+
70
+ # Entrypoint to start the runner process once it finishes parsing the log.
71
+ #
72
+ # @return [Void]
73
+ def start!
74
+ if ruby_version && !ruby_path.exist?
75
+ ::CLI::UI.puts(<<~EOM)
76
+ {{warning:Couldn't find Ruby version #{ruby_version} on your system.}}
77
+ {{warning:Searched in #{ruby_path}}}
78
+
79
+ {{warning:The test run will start but will be running using your current Ruby version {{underline:#{RUBY_VERSION}}}.}}
80
+ EOM
81
+ end
82
+
83
+ if gemfile && !gemfile_path.exist?
84
+ ::CLI::UI.puts(<<~EOM)
85
+ {{warning:Your CI run ran with the Gemfile #{gemfile}}}
86
+ {{warning:I couldn't find this gemfile in your folder.}}
87
+
88
+ {{warning:The test run will start but will be using the default Gemfile of your project}}
89
+ EOM
90
+ end
91
+ end
92
+
93
+ # Output useful information to the user before the test starts. This can only be called after the runner finished
94
+ # parsing the log.
95
+ #
96
+ # @return [void]
97
+ def report
98
+ default_ruby = "No specific Ruby version detected. Will be using your current version #{RUBY_VERSION}"
99
+ using_ruby = ruby_version ? ruby_version : default_ruby
100
+
101
+ default_gemfile = "No specific Gemfile detected. Will be using the default Gemfile of your project."
102
+ using_gemfile = gemfile ? gemfile : default_gemfile
103
+
104
+ ::CLI::UI.puts(<<~EOM)
105
+
106
+ - Test framework detected: {{info:#{name}}}
107
+ - Detected Ruby version: {{info:#{using_ruby}}}
108
+ - Detected Gemfile: {{info:#{using_gemfile}}}
109
+ - Number of failings tests: {{info:#{failures.count}}}
110
+ EOM
111
+ end
112
+
113
+ private
114
+
115
+ # Process the +@buffer+ to find any test failures. Uses the project's regex if set, or fallbacks to
116
+ # the default set of regexes this gem provides.
117
+ #
118
+ # See Project#buffer_starts_regex for explanation on the difference between the buffer and the CI log output.
119
+ #
120
+ # @return [void]
121
+ def process_buffer
122
+ custom_project_regex = Configuration::Project.instance.test_failure_detection_regex
123
+
124
+ if custom_project_regex
125
+ custom_project_regex.match(@buffer) do |match_data|
126
+ @failures << TestFailure.new(match_data[:class], match_data[:test_name], match_data[:file_path])
127
+ end
128
+ else
129
+ yield
130
+ end
131
+ end
132
+
133
+ # See Configuration::Project#ruby_detection_regex
134
+ #
135
+ # @return [Regexp]
136
+ def ruby_detection_regex
137
+ return @ruby_detection_regex if defined?(@ruby_detection_regex)
138
+
139
+ regexes = [
140
+ Configuration::Project.instance.ruby_detection_regex,
141
+ %r{[^_-][rR]uby(?:[[:blank:]]*|/)(\d\.\d\.\d+)p?(?!/gems)},
142
+ ].compact
143
+
144
+ @ruby_detection_regex = Regexp.union(*regexes)
145
+ end
146
+
147
+ # See Configuration::Project#gemfile_detection_regex
148
+ #
149
+ # @return [Regexp]
150
+ def gemfile_detection_regex
151
+ return @gemfile_detection_regex if defined?(@gemfile_detection_regex)
152
+
153
+ regexes = [
154
+ Configuration::Project.instance.gemfile_detection_regex,
155
+ /BUNDLE_GEMFILE:[[:blank:]]*(.*)/,
156
+ ].compact
157
+
158
+ @gemfile_detection_regex = Regexp.union(*regexes)
159
+ end
160
+
161
+ # See Configuration::Project#seed_detection_regex
162
+ #
163
+ # @return [Regexp]
164
+ def seed_regex
165
+ return @seed_regex if defined?(@seed_regex)
166
+
167
+ regexes = [
168
+ Configuration::Project.instance.seed_detection_regex,
169
+ self.class::SEED_REGEX,
170
+ ].compact
171
+
172
+ @seed_regex = Regexp.union(*regexes)
173
+ end
174
+
175
+ # See Configuration::Project#buffer_starts_regex
176
+ #
177
+ # @return [Regexp]
178
+ def buffer_detection_regex
179
+ return @buffer_detection_regex if defined?(@buffer_detection_regex)
180
+
181
+ regexes = [
182
+ Configuration::Project.instance.buffer_starts_regex,
183
+ self.class::BUFFER_STARTS,
184
+ ].compact
185
+
186
+ @buffer_detection_regex = Regexp.union(*regexes)
187
+ end
188
+
189
+ # @return [Pathname, nil] The absolute path of the detected Gemfile based on where the user ran
190
+ # the `ci_runner` command from. Nil in no Gemfile was detected during parsing.
191
+ def gemfile_path
192
+ return unless gemfile
193
+
194
+ Pathname(Dir.pwd).join(gemfile)
195
+ end
196
+
197
+ # @return [Pathname, nil] The absolute path of the Ruby binary on the user's machine.
198
+ # Nil if no Ruby version was detected when parsing.
199
+ #
200
+ # @return [Pathname]
201
+ def ruby_path
202
+ return unless ruby_version
203
+
204
+ Pathname(Dir.home).join(".rubies/ruby-#{ruby_version}/bin/ruby")
205
+ end
206
+
207
+ # @return [Boolean]
208
+ def buffering?
209
+ !@buffer.empty?
210
+ end
211
+
212
+ # Regexp#union with capturing groups makes it difficult to know which subregex matched
213
+ # and therefore which group to get. Convenient method to get the first whatever value is non nil.
214
+ # There should be only one truty value in all groups.
215
+ #
216
+ # @param match_data [MatchData]
217
+ #
218
+ # @return [String]
219
+ def first_matching_group(match_data)
220
+ match_data.captures.find { |v| v }&.rstrip
221
+ end
222
+
223
+ # Runs a command and stream its output. We can't use `system` directly, as otherwise the
224
+ # streamed ouput won't fit properly inside the ::CLI::UI frame.
225
+ #
226
+ # @param env [Hash] A hash of environment variables to pass to the subprocess
227
+ # @param command [String] The command itself
228
+ #
229
+ # @return [void]
230
+ def execute_within_frame(env, command)
231
+ Open3.popen3(env, command) do |_, stdout, stderr, _|
232
+ while (char = stdout.getc)
233
+ print(char)
234
+ end
235
+
236
+ print(stderr.read)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require "drb/drb"
5
+ require "tempfile"
6
+ require "rake"
7
+
8
+ module CIRunner
9
+ module Runners
10
+ # Runner responsible to detect parse and process a CI output log generated by Minitest.
11
+ #
12
+ # Because minitest doesn't have a CLI built-in, there is a lot of complications to re-run
13
+ # a selection of tests, especially when we need to run the tests in a subprocess.
14
+ #
15
+ # In a nutshell, this Runner will try its best to get the file path of each failures.
16
+ # Without a custom repoter, Minitest will fail pointing to the right file a lot of the time.
17
+ # Therefore CI Runner tries a mix of possibilities (by looking at the stack trace, inferring the class name).
18
+ #
19
+ # Once the logs have been parsed, we tell Ruby to load only the test files that failed. By default, loading
20
+ # those files would make Minitest run all tests included in those files, where we want to run only tests
21
+ # that failed on CI.
22
+ #
23
+ # Minitest doesn't have a way to filter by test name (the -n) isn't powerful enough as two test suites can
24
+ # contain the same name.
25
+ # CI Runner launches a DRB server over a UNIX socket which allows allows the subprocess running minitest
26
+ # to know whether a test should ran. This is accomplished in combination with a Minitest plugin.
27
+ #
28
+ # A vanilla rake task plugs the whole thing in order to not reinvent the wheel.
29
+ class MinitestRunner < Base
30
+ SEED_REGEX = Regexp.union(
31
+ /Run options:.*?--seed\s+(\d+)/, # Default Minitest Statistics Repoter
32
+ /Running tests with run options.*--seed\s+(\d+)/, # MinitestReporters BaseReporter
33
+ /Started with run options.*--seed\s+(\d+)/, # MinitestReporters ProgressReporter
34
+ )
35
+ BUFFER_STARTS = /(Failure|Error):\s*\Z/
36
+
37
+ # @param ci_log [String] The CI log output
38
+ #
39
+ # @return [Boolean] Whether this runner detects (and therefore can handle) Minitest from the log output.
40
+ def self.match?(ci_log)
41
+ default_reporter = %r{(Finished in) \d+\.\d{6}s, \d+\.\d{4} runs/s, \d+\.\d{4} assertions/s\.}
42
+
43
+ Regexp.union(default_reporter, SEED_REGEX, "minitest").match?(ci_log)
44
+ end
45
+
46
+ # @return [String] See Runners::Base#report
47
+ def name
48
+ "Minitest"
49
+ end
50
+
51
+ # Start a subprocess to rerun the detected failing tests.
52
+ #
53
+ # Few things to note:
54
+ #
55
+ # - CI Runner is meant to be installed as a standalone gem, not Bundled (in a Gemfile). CI Runner
56
+ # doesn't know what's inside the loaded specs of the application and it's possible (while unlikely)
57
+ # that "Rake" isn't part of the application/gem dependencies. Therefore, when activativing Bundler,
58
+ # requiring "rake/testtask" would fail inside the Rakefile.
59
+ #
60
+ # To avoid this problem, requiring rake before Bundler gets activated (using the ruby -rswitch).
61
+ #
62
+ # - The CI Runner Minitest plugin will not be detected once the subprocess starts. (Again because CI Runner
63
+ # is not part of the application dependencies). Adding it to the LOAD_PATH manually is required.
64
+ def start!
65
+ super
66
+
67
+ minitest_plugin_path = File.expand_path("../..", __dir__)
68
+ rake_load_path = Gem.loaded_specs["rake"].full_require_paths.first
69
+
70
+ code = <<~EOM
71
+ Rake::TestTask.new(:__ci_runner_test) do |t|
72
+ t.libs << "test"
73
+ t.libs << "lib"
74
+ t.libs << "#{rake_load_path}"
75
+ t.libs << "#{minitest_plugin_path}"
76
+ t.test_files = #{failures.map(&:path)}
77
+ end
78
+
79
+ Rake::Task[:__ci_runner_test].invoke
80
+ EOM
81
+
82
+ rakefile_path = File.expand_path("Rakefile", Dir.mktmpdir)
83
+ File.write(rakefile_path, code)
84
+
85
+ server = DRb.start_service("drbunix:", failures)
86
+
87
+ env = { "TESTOPTS" => "--ci-runner=#{server.uri}" }
88
+ env["SEED"] = seed if seed
89
+ env["RUBY"] = ruby_path.to_s if ruby_path&.exist?
90
+ env["BUNDLE_GEMFILE"] = gemfile_path.to_s if gemfile_path&.exist?
91
+
92
+ execute_within_frame(env, "bundle exec ruby -r'rake/testtask' #{rakefile_path}")
93
+
94
+ DRb.stop_service
95
+ end
96
+
97
+ private
98
+
99
+ def process_buffer
100
+ super do
101
+ match_data = minitest_failure
102
+ next unless match_data
103
+
104
+ file_path = valid_path?(match_data[:file_path]) ? match_data[:file_path] : find_test_location(match_data)
105
+
106
+ @failures << TestFailure.new(match_data[:class], match_data[:test_name], file_path)
107
+ end
108
+ end
109
+
110
+ # Detect if the buffer contains a Minitest Error/Failure. If not, bail out.
111
+ #
112
+ # @return [nil, MatchData] Whether the regex matched.
113
+ def minitest_failure
114
+ regex = /(?:\s*)(?<class>[a-zA-Z0-9_:]+)\#(?<test_name>test_.+?)(:\s*$|\s+\[(?<file_path>.*):\d+\])/
115
+
116
+ regex.match(@buffer)
117
+ end
118
+
119
+ # There are two different type of errors in Minitest: Failure and Error.
120
+ # A failure is an assertion that failed `assert(false)`. An error in an unexpected exception `foo.boom`.
121
+ #
122
+ # For failure, Minitest will print the location of the test file, but it can be wrong.
123
+ #
124
+ # MaintenanceTasks::RunsTest#test_run_a_CSV_Task [/home/runner/work/maintenance_tasks/maintenance_tasks/vendor/bundle/ruby/2.7.0/gems/capybara-3.37.1/lib/capybara/minitest.rb:295] # rubocop:disable Layout/LineLength
125
+ #
126
+ # In this case the location of the file points to a file inside a gem, which is for sure not where the test lives.
127
+ # When this happen, we discard the location provided by Minitest and try to match another possible location.
128
+ #
129
+ # @param path [String] A path to a file
130
+ #
131
+ # @return [Boolean] Whether the path points to a gem.
132
+ def valid_path?(path)
133
+ return false if path.nil?
134
+
135
+ points_to_a_gem = %r{ruby/.*?/gems}
136
+
137
+ !path.match?(points_to_a_gem)
138
+ end
139
+
140
+ # Try different alternatives to find where the test file lives.
141
+ #
142
+ # @param match_data [MatchData] The match from the +minitest_failures+
143
+ #
144
+ # @return [String] The path of the test file.
145
+ def find_test_location(match_data)
146
+ match = try_rails
147
+ return match if match
148
+
149
+ match = try_infer_file_from_class(match_data)
150
+ return match if match
151
+
152
+ match = try_stacktrace(match_data)
153
+ return match if match
154
+
155
+ raise("Can't find test location")
156
+ end
157
+
158
+ # Taken from ActiveSupport
159
+ #
160
+ # @param camel_cased_word [String]
161
+ #
162
+ # @return [String]
163
+ def underscore(camel_cased_word)
164
+ return camel_cased_word.to_s unless /[A-Z-]|::/.match?(camel_cased_word)
165
+
166
+ word = camel_cased_word.to_s.gsub("::", "/")
167
+ word.gsub!(/([A-Z]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) do
168
+ (Regexp.last_match(1) || Regexp.last_match(2)) << "_"
169
+ end
170
+ word.tr!("-", "_")
171
+ word.downcase!
172
+ word
173
+ end
174
+
175
+ # Try to find the file location using the class name we matched.
176
+ #
177
+ # @param match_data [MatchData] The match from the +minitest_failures+
178
+ #
179
+ # @return [String, nil] The path of the test file. Nil if not found.
180
+ #
181
+ # @example
182
+ # Given a failure: TestReloading#test_reload_recovers_from_name_errors__w__on_unload_callbacks_:
183
+ # We try to look for a line from the stacktrace like this one:
184
+ # /Users/runner/wok/zeitwerk/zeitwerk/test/lib/zeitwerk/test_reloading.rb:12:in `block in <class:TestReloading>'
185
+ def try_stacktrace(match_data)
186
+ regex = %r{\s*(/.*?):\d+:in.*#{match_data[:class]}}
187
+
188
+ @buffer.match(regex) { |match| match[1] }
189
+ end
190
+
191
+ # Try to find the file location using the class_name -> file convention naming
192
+ #
193
+ # @param match_data [MatchData] The match from the +minitest_failures+
194
+ #
195
+ # @return [String, nil] The path of the test file. Nil if not found.
196
+ #
197
+ # @example
198
+ # Given a failure: BigProblemTest#test_this_is_problematic
199
+ #
200
+ # We try to look for a line from the stacktrace like this one:
201
+ # /Users/runner/work/foo/foo/test/lib/foo/big_problem_test.rb:218
202
+ def try_infer_file_from_class(match_data)
203
+ file_name = underscore(match_data[:class].split("::").last)
204
+ regex = %r{(/.*#{file_name}.*?):\d+}
205
+
206
+ @buffer.match(regex) { |match| match[1] }
207
+ end
208
+
209
+ # Try to find the file location using the Rails reporter
210
+ #
211
+ # @param match_data [MatchData] The match from the +minitest_failures+
212
+ #
213
+ # @return [String, nil] The path of the test file. Nil if not found.
214
+ def try_rails
215
+ regex = /rails\s+test\s+(.*?):\d+/
216
+
217
+ @buffer.match(regex) { |match| match[1] }
218
+ end
219
+ end
220
+ end
221
+ end