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.
- checksums.yaml +7 -0
- data/ci_runner.gemspec +43 -0
- data/exe/ci_runner +7 -0
- data/lib/ci_runner/cli.rb +178 -0
- data/lib/ci_runner/configuration/project.rb +242 -0
- data/lib/ci_runner/configuration/user.rb +97 -0
- data/lib/ci_runner/git_helper.rb +103 -0
- data/lib/ci_runner/github_client.rb +105 -0
- data/lib/ci_runner/log_downloader.rb +94 -0
- data/lib/ci_runner/runners/base.rb +241 -0
- data/lib/ci_runner/runners/minitest_runner.rb +221 -0
- data/lib/ci_runner/runners/rspec.rb +69 -0
- data/lib/ci_runner/test_failure.rb +65 -0
- data/lib/ci_runner/test_run_finder.rb +114 -0
- data/lib/ci_runner/version.rb +5 -0
- data/lib/ci_runner.rb +25 -0
- data/lib/minitest/ci_runner_plugin.rb +32 -0
- metadata +155 -0
@@ -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
|