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