ci_runner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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