jitcatch 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/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module JitCatch
|
|
11
|
+
# Runs each candidate file on base and head worktrees and classifies it.
|
|
12
|
+
class Runner
|
|
13
|
+
FILE_CAP = 40
|
|
14
|
+
PASSED_ENV = %w[PATH HOME USER LOGNAME TMPDIR TEMP TMP LD_LIBRARY_PATH GEM_HOME GEM_PATH].freeze
|
|
15
|
+
FIXED_ENV = { "LANG" => "C.UTF-8", "LC_ALL" => "C.UTF-8", "TERM" => "dumb" }.freeze
|
|
16
|
+
# Names jitcatch sets itself, so --env cannot pass them.
|
|
17
|
+
RESERVED_ENV = (FIXED_ENV.keys + %w[BUNDLE_GEMFILE TEST_ENV_NUMBER]).freeze
|
|
18
|
+
# How a child reaches the network. Tools read both https_proxy and
|
|
19
|
+
# HTTPS_PROXY, so the case does not matter.
|
|
20
|
+
NETWORK_ENV = /\A(?:(?:HTTPS?|NO|ALL)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS)\z/i
|
|
21
|
+
ENV_READ = /ENV(?:\.fetch\(\s*|\.key\?\(\s*|\[\s*)["']([A-Za-z_][A-Za-z0-9_]*)["']/
|
|
22
|
+
ENV_READ_GREP = %q{ENV(\.fetch\(|\.key\?\(|\[) *["'][A-Za-z_][A-Za-z0-9_]*["']}
|
|
23
|
+
# What reads a proxy or CA setting: an HTTP client, by the name a file
|
|
24
|
+
# requires or a Gemfile.lock lists, and a command a file starts that
|
|
25
|
+
# fetches, such as `npx --yes` or `bundle install`, in a string, a
|
|
26
|
+
# backtick, or an argument list. A spec of a local SQLite app uses none,
|
|
27
|
+
# so the host's proxy settings are noise there. A command spelled any
|
|
28
|
+
# other way, as in a script file, is not seen.
|
|
29
|
+
NETWORK_REQUIRES = %w[net/http net/https open-uri faraday httparty excon http httpx httpclient rest-client typhoeus].freeze
|
|
30
|
+
NETWORK_GEMS = %w[net-http faraday httparty excon http httpx httpclient rest-client typhoeus].freeze
|
|
31
|
+
NETWORK_COMMANDS = %w[npx npm curl wget].freeze
|
|
32
|
+
FETCHING_TOOLS = %w[bundle gem git].freeze
|
|
33
|
+
FETCHING_SUBCOMMANDS = %w[install update clone fetch pull].freeze
|
|
34
|
+
NETWORK_USE_GREP = "require *\\(? *[\"'](#{NETWORK_REQUIRES.join("|")})[\"']|[\"'`](#{NETWORK_COMMANDS.join("|")}) " \
|
|
35
|
+
"|[\"'`](#{FETCHING_TOOLS.join("|")})([\"'], *[\"']| +)(#{FETCHING_SUBCOMMANDS.join("|")})"
|
|
36
|
+
NETWORK_USE = Regexp.new(NETWORK_USE_GREP)
|
|
37
|
+
# Rescuing in the child keeps Bundler's own message and drops its backtrace.
|
|
38
|
+
BUNDLE_SETUP = 'begin; require "bundler/setup"; rescue Exception => e; $stderr.puts e.message; exit 1; end'
|
|
39
|
+
SETUP_STDERR_LINES = 10
|
|
40
|
+
# A database server that refused the connection, as PostgreSQL and MySQL
|
|
41
|
+
# clients word it. The client then took its host and port from a default,
|
|
42
|
+
# often because the shell's setting for them was not passed.
|
|
43
|
+
DATABASE_REFUSED = /Connection refused|could not connect to server|Can't connect to (?:local )?(?:MySQL )?server/
|
|
44
|
+
# What an app's database.yml or its client library reads: libpq reads
|
|
45
|
+
# PG* itself, and Rails reads DATABASE_URL and its per-database forms.
|
|
46
|
+
DATABASE_ENV = /\A(?:(?:\w+_)?DATABASE_URL|DB_\w+|PG[A-Z]+|MYSQL_\w+)\z/
|
|
47
|
+
SQLITE_RELATIVE = "relative path, as sqlite3:storage/test.sqlite3, so each checkout opens a file under its own root"
|
|
48
|
+
# The test databases of a side, as Active Record reads config/database.yml
|
|
49
|
+
# with that side's TEST_ENV_NUMBER and the variables --env passes, so a
|
|
50
|
+
# DATABASE_URL merges in as it does when rails_helper sets RAILS_ENV.
|
|
51
|
+
TEST_DATABASES = <<~'SCRIPT'
|
|
52
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
53
|
+
require "json"
|
|
54
|
+
require "active_record"
|
|
55
|
+
require "active_record/database_configurations"
|
|
56
|
+
require "active_support/configuration_file"
|
|
57
|
+
configs = ActiveRecord::DatabaseConfigurations.new(ActiveSupport::ConfigurationFile.parse("config/database.yml")).configs_for(env_name: "test")
|
|
58
|
+
print JSON.generate(configs.map { |config| config.configuration_hash.slice(:adapter, :host, :port, :socket, :database).transform_values(&:to_s) })
|
|
59
|
+
SCRIPT
|
|
60
|
+
# `lines` maps an RSpec example's name to the line it starts on.
|
|
61
|
+
Result = Data.define(:tests, :lines, :file_error, :timed_out, :exit_code, :seconds)
|
|
62
|
+
# One worker's two checkouts. A worker runs one file at a time in them.
|
|
63
|
+
Pair = Data.define(:base, :head)
|
|
64
|
+
# The names a test can mention, and the constants head defines.
|
|
65
|
+
# `unsaved` maps a `build(:loan)` that only a callback lists to the
|
|
66
|
+
# record calls that make it run that callback.
|
|
67
|
+
# `routes` are the routes head's config/routes.rb draws.
|
|
68
|
+
Known = Data.define(:names, :constants, :unsaved, :routes)
|
|
69
|
+
|
|
70
|
+
def self.run(repo, base_ref, head_ref, tests_dir, reruns: 2, timeout: nil, env: [], workers: 1, serial: false, number_database_url: false, setup: nil, finished_in_time: nil)
|
|
71
|
+
new(repo, base_ref, head_ref, tests_dir, reruns: reruns, timeout: timeout, env: env, workers: workers, serial: serial,
|
|
72
|
+
number_database_url: number_database_url, setup: setup, finished_in_time: finished_in_time).run
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `finished_in_time` takes the thread that waits on a child and says
|
|
76
|
+
# whether the child ended before the timeout. A test passes its own so
|
|
77
|
+
# that which child times out does not depend on the machine's load.
|
|
78
|
+
# `setup` is a shell command run once in each checkout before its
|
|
79
|
+
# candidates, such as a schema load into that checkout's database.
|
|
80
|
+
def initialize(repo, base_ref, head_ref, tests_dir, reruns:, timeout:, env:, workers: 1, serial: false, number_database_url: false, setup: nil, finished_in_time: nil)
|
|
81
|
+
@git = repo.is_a?(GitRepo) ? repo : GitRepo.new(repo)
|
|
82
|
+
@setup = setup
|
|
83
|
+
@base_ref = base_ref
|
|
84
|
+
@head_ref = head_ref
|
|
85
|
+
@tests_dir = File.expand_path(tests_dir)
|
|
86
|
+
@reruns = reruns
|
|
87
|
+
@timeout = timeout
|
|
88
|
+
@finished_in_time = finished_in_time || ->(waiter) { !waiter.join(@timeout).nil? }
|
|
89
|
+
@env_names = env.uniq.sort
|
|
90
|
+
@workers = workers
|
|
91
|
+
raise UsageError, "--workers must be at least 1" unless workers.is_a?(Integer) && workers >= 1
|
|
92
|
+
# Candidates that share a fixed path or port outside the worktree
|
|
93
|
+
# collide when two processes run at once; serial runs one at a time.
|
|
94
|
+
raise UsageError, "--serial runs one process at a time, so it takes no --workers above 1" if serial && workers > 1
|
|
95
|
+
|
|
96
|
+
@serial = serial
|
|
97
|
+
raise UsageError, "tests directory not found: #{tests_dir}" unless File.directory?(@tests_dir)
|
|
98
|
+
|
|
99
|
+
@number_database_url = number_database_url
|
|
100
|
+
check_database_url if number_database_url
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_database_url
|
|
104
|
+
raise UsageError, "--number-database-url numbers the DATABASE_URL that --env passes, so pass --env DATABASE_URL" unless @env_names.include?("DATABASE_URL")
|
|
105
|
+
return if ENV["DATABASE_URL"] && numbered_database_url(ENV["DATABASE_URL"], "1")
|
|
106
|
+
raise UsageError, "--number-database-url numbers only a database server's database, and DATABASE_URL names an SQLite file; give it a #{SQLITE_RELATIVE}" if sqlite_url?(ENV["DATABASE_URL"])
|
|
107
|
+
|
|
108
|
+
raise UsageError, "--number-database-url needs DATABASE_URL to name a database, as postgresql://localhost/app_test does; it #{ENV["DATABASE_URL"] ? "names none" : "is not set"}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# An SQLite URL names a file, not a database on a server, so there is
|
|
112
|
+
# no database to number; a relative path already gives each checkout
|
|
113
|
+
# its own file.
|
|
114
|
+
def sqlite_url?(url)
|
|
115
|
+
url.to_s.match?(/\Asqlite3?:/i)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# `postgresql://host/app_test_wt1?sslmode=disable` with number 2 is
|
|
119
|
+
# `postgresql://host/app_test_wt1_2?sslmode=disable`. Nil when the URL
|
|
120
|
+
# names no database on a server.
|
|
121
|
+
def numbered_database_url(url, number)
|
|
122
|
+
return nil if sqlite_url?(url)
|
|
123
|
+
|
|
124
|
+
uri = URI.parse(url)
|
|
125
|
+
name = uri.path.to_s.delete_prefix("/")
|
|
126
|
+
return nil if name.empty? || name.include?("/")
|
|
127
|
+
|
|
128
|
+
uri.path = "/#{name}_#{number}"
|
|
129
|
+
uri.to_s
|
|
130
|
+
rescue URI::InvalidURIError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run
|
|
135
|
+
files, omitted = candidate_files
|
|
136
|
+
@frameworks = files.to_h { |relative| [relative, Framework.for(relative, read_candidate(relative))] }
|
|
137
|
+
@pair_count = [@workers, files.size].min.clamp(1, nil)
|
|
138
|
+
changes = Changes.new(@git, @base_ref, @head_ref, setup: @setup, databases: numbered_databases)
|
|
139
|
+
change = changes.build
|
|
140
|
+
parent = Dir.mktmpdir("jitcatch-")
|
|
141
|
+
pairs = (1..@pair_count).map { |n|
|
|
142
|
+
Pair.new(base: File.join(parent, n.to_s, "base"), head: File.join(parent, n.to_s, "head"))
|
|
143
|
+
}
|
|
144
|
+
# The variable parallel_tests sets: a Rails database.yml that appends
|
|
145
|
+
# it to the database name gives each checkout its own database, so base
|
|
146
|
+
# and head never load their schemas into one database at the same time.
|
|
147
|
+
@test_env_numbers = pairs.each_with_index.flat_map { |pair, index| [[pair.base, (2 * index + 1).to_s], [pair.head, (2 * index + 2).to_s]] }.to_h
|
|
148
|
+
begin
|
|
149
|
+
# One at a time: git takes a lock on the repository for each add.
|
|
150
|
+
pairs.each { |pair| check_out(pair, change) }
|
|
151
|
+
rails_app = File.file?(File.join(pairs.first.head, "config", "application.rb"))
|
|
152
|
+
# A Rails app boots and loads db/schema.rb in each new checkout.
|
|
153
|
+
@timeout ||= rails_app ? 60 : 10
|
|
154
|
+
normalizer = build_normalizer([*pairs.flat_map { |pair| [pair.base, pair.head] }, @git.dir])
|
|
155
|
+
first = pairs.first
|
|
156
|
+
frameworks = @frameworks.values.uniq
|
|
157
|
+
base_setup = Thread.new { setup_status("base", change["base"], first.base, normalizer, frameworks) }
|
|
158
|
+
head_setup = setup_status("head", change["head"], first.head, normalizer, frameworks)
|
|
159
|
+
setups = [base_setup.value, head_setup]
|
|
160
|
+
failed = setups.filter_map { |setup| setup["failure"] }
|
|
161
|
+
raise EnvironmentError, setup_failed_doc(change, failed, rails_app: rails_app) unless failed.empty?
|
|
162
|
+
|
|
163
|
+
ran = run_setup(pairs, change, normalizer)
|
|
164
|
+
raise EnvironmentError, setup_failed_doc(change, ran, rails_app: rails_app, stage: "setup_command") unless ran.empty?
|
|
165
|
+
|
|
166
|
+
bundle = { "base" => setups[0]["bundle"], "head" => setups[1]["bundle"] }
|
|
167
|
+
shared = shared_test_database(first)
|
|
168
|
+
@serial ||= !shared.nil?
|
|
169
|
+
routes_file = File.join(first.head, "config", "routes.rb")
|
|
170
|
+
inflections_file = File.join(first.head, "config", "initializers", "inflections.rb")
|
|
171
|
+
routes = Routes.parse(File.file?(routes_file) ? read_text(routes_file) : nil, Inflections.read(File.file?(inflections_file) ? read_text(inflections_file) : nil))
|
|
172
|
+
helper_warnings = File.file?(routes_file) ? route_helper_warnings(files, routes) : []
|
|
173
|
+
known = Known.new(names: mention_names(change), constants: changes.head_constants, unsaved: unsaved_mentions(change), routes: routes)
|
|
174
|
+
rows = classify_files(files, shared ? [first] : pairs, normalizer, known)
|
|
175
|
+
bundled = gemfile?(first.head)
|
|
176
|
+
spec_root = File.exist?(File.join(first.head, "spec"))
|
|
177
|
+
launcher = @frameworks.value?(Framework::RSpec) && launcher_loads?
|
|
178
|
+
rows.each { |row| row["rerun"] = rerun_command(row, bundled, !spec_root, launcher) }
|
|
179
|
+
unloaded = load_failure_doc(change, rows, bundled, rails_app)
|
|
180
|
+
raise EnvironmentError, unloaded if unloaded
|
|
181
|
+
ensure
|
|
182
|
+
pairs.each do |pair|
|
|
183
|
+
@git.worktree_remove(pair.base)
|
|
184
|
+
@git.worktree_remove(pair.head)
|
|
185
|
+
end
|
|
186
|
+
@git.worktree_prune
|
|
187
|
+
FileUtils.remove_entry(parent) if parent && File.directory?(parent)
|
|
188
|
+
end
|
|
189
|
+
assign_test_ids(rows)
|
|
190
|
+
{
|
|
191
|
+
"version" => VERSION,
|
|
192
|
+
"command" => "run",
|
|
193
|
+
"base" => change["base"],
|
|
194
|
+
"head" => change["head"],
|
|
195
|
+
"warnings" => change["warnings"] + require_warnings(files) + helper_warnings + [*(shared_database_warnings(shared) if shared)],
|
|
196
|
+
"reruns" => @reruns,
|
|
197
|
+
"timeout_s" => @timeout,
|
|
198
|
+
"serial" => @serial,
|
|
199
|
+
"setup" => @setup,
|
|
200
|
+
"numbered_databases" => numbered_databases,
|
|
201
|
+
"env" => @env_names,
|
|
202
|
+
"env_unset" => @env_names.reject { |name| ENV.key?(name) },
|
|
203
|
+
"rails_app" => rails_app,
|
|
204
|
+
"env_not_passed" => rows.any? { |row| %w[base head].any? { |side| row.dig(side, "status") == "timeout" } } ? env_not_passed(change["head"], files) : [],
|
|
205
|
+
"bundle" => bundle,
|
|
206
|
+
"tests_dir" => tests_from_root,
|
|
207
|
+
"test_files_omitted" => omitted,
|
|
208
|
+
"dependencies" => change["dependencies"],
|
|
209
|
+
"methods" => change["methods"].map { |method| slim_method(method) },
|
|
210
|
+
"constants" => change["constants"].map { |const| slim_constant(const) },
|
|
211
|
+
"changed_lines" => change["changed_lines"],
|
|
212
|
+
"tests" => rows
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def check_out(pair, change)
|
|
219
|
+
if @git.empty_tree?(change["base"])
|
|
220
|
+
FileUtils.mkdir_p(pair.base)
|
|
221
|
+
else
|
|
222
|
+
@git.worktree_add(pair.base, change["base"])
|
|
223
|
+
end
|
|
224
|
+
@git.worktree_add(pair.head, change["head"])
|
|
225
|
+
[pair.base, pair.head].each do |worktree|
|
|
226
|
+
install(worktree)
|
|
227
|
+
copy_bundler_settings(worktree)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Each worker takes the next file and runs it in its own pair, so the
|
|
232
|
+
# rows do not depend on which worker ran which file.
|
|
233
|
+
def classify_files(files, pairs, normalizer, known)
|
|
234
|
+
queue = Queue.new
|
|
235
|
+
files.each { |relative| queue << relative }
|
|
236
|
+
queue.close
|
|
237
|
+
threads = pairs.map { |pair|
|
|
238
|
+
Thread.new do
|
|
239
|
+
found = []
|
|
240
|
+
while (relative = queue.pop)
|
|
241
|
+
found.concat(classify_file(relative, pair.base, pair.head, normalizer, known))
|
|
242
|
+
end
|
|
243
|
+
found
|
|
244
|
+
end
|
|
245
|
+
}
|
|
246
|
+
threads.flat_map(&:value)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def classify_file(relative, base_dir, head_dir, normalizer, known)
|
|
250
|
+
source = read_candidate(relative)
|
|
251
|
+
index = SourceIndex.parse(source, file: relative)
|
|
252
|
+
# Each side runs in its own worktree, so the two can run at once.
|
|
253
|
+
if @serial
|
|
254
|
+
base_run = execute(base_dir, relative, normalizer)
|
|
255
|
+
head_run = execute(head_dir, relative, normalizer)
|
|
256
|
+
else
|
|
257
|
+
base_thread = Thread.new { execute(base_dir, relative, normalizer) }
|
|
258
|
+
head_run = execute(head_dir, relative, normalizer)
|
|
259
|
+
base_run = base_thread.value
|
|
260
|
+
end
|
|
261
|
+
rows = rows_for(relative, base_run, head_run, index, known, head_dir)
|
|
262
|
+
return rows unless rows.any? { |row| row["classification"] == "candidate" }
|
|
263
|
+
return rows if head_run.timed_out || base_run.timed_out
|
|
264
|
+
|
|
265
|
+
@reruns.times do
|
|
266
|
+
rerun = execute(head_dir, relative, normalizer)
|
|
267
|
+
rows.each do |row|
|
|
268
|
+
next unless row["classification"] == "candidate"
|
|
269
|
+
next if load_row?(row)
|
|
270
|
+
|
|
271
|
+
again = rerun.tests[row["name"]] || missing_result
|
|
272
|
+
(row["reruns"] ||= []) << again.slice("status", "error_class", "message_first_line", "message")
|
|
273
|
+
next if failure_key(again) == failure_key(row["head"])
|
|
274
|
+
|
|
275
|
+
if outcome_key(again) == outcome_key(row["head"]) && row.dig("base", "status") == "pass"
|
|
276
|
+
row["message_varies"] = true
|
|
277
|
+
else
|
|
278
|
+
row["classification"] = "flaky"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
break if rows.none? { |row| row["classification"] == "candidate" }
|
|
282
|
+
end
|
|
283
|
+
confirm_on_base(rows.select { |row| row["classification"] == "candidate" && row["message_varies"] }, base_dir, relative, normalizer)
|
|
284
|
+
rows
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A test whose head fails the same way each time, with a value in the
|
|
288
|
+
# message that moves, such as a random id, still caught the change when
|
|
289
|
+
# base passes each time too. A test that fails at random is caught here
|
|
290
|
+
# instead: its base fails once in a while as well.
|
|
291
|
+
def confirm_on_base(rows, base_dir, relative, normalizer)
|
|
292
|
+
return if rows.empty?
|
|
293
|
+
|
|
294
|
+
@reruns.times do
|
|
295
|
+
rerun = execute(base_dir, relative, normalizer)
|
|
296
|
+
rows.each do |row|
|
|
297
|
+
again = rerun.tests[row["name"]] || missing_result
|
|
298
|
+
(row["base_reruns"] ||= []) << again.slice("status", "error_class", "message_first_line", "message")
|
|
299
|
+
row["classification"] = "flaky" unless again["status"] == "pass"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def rows_for(relative, base_run, head_run, index, known, head_dir)
|
|
305
|
+
if file_level?(base_run) || file_level?(head_run)
|
|
306
|
+
name = file_level_name(relative, base_run, head_run)
|
|
307
|
+
base_result = side_result(base_run)
|
|
308
|
+
# Only beside a head that timed out does base's time say anything:
|
|
309
|
+
# how far head was from base when it ran out. Kept nowhere else, so
|
|
310
|
+
# every other row stays the same bytes from run to run.
|
|
311
|
+
base_result = base_result.merge("seconds" => base_run.seconds.round(1)) if head_run.timed_out && base_result["status"] == "pass"
|
|
312
|
+
[build_row(relative, name, nil, base_result, side_result(head_run), mentions_for(index, name, nil, known, relative, head_dir))]
|
|
313
|
+
else
|
|
314
|
+
names = (base_run.tests.keys | head_run.tests.keys).sort
|
|
315
|
+
names.map { |name|
|
|
316
|
+
line = head_run.lines[name] || base_run.lines[name]
|
|
317
|
+
build_row(
|
|
318
|
+
relative,
|
|
319
|
+
name,
|
|
320
|
+
line,
|
|
321
|
+
base_run.tests[name],
|
|
322
|
+
head_run.tests[name],
|
|
323
|
+
mentions_for(index, name, line, known, relative, head_dir)
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def file_level?(execution)
|
|
330
|
+
execution.timed_out || execution.file_error
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# A test of its own can also end in "#load", so match the whole name.
|
|
334
|
+
def load_row?(row)
|
|
335
|
+
row["name"] == "#{File.basename(row["file"])}#load"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def file_level_name(relative, base_run, head_run)
|
|
339
|
+
timed_out = base_run.timed_out || head_run.timed_out
|
|
340
|
+
"#{File.basename(relative)}##{timed_out ? "timeout" : "load"}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def side_result(execution)
|
|
344
|
+
return timeout_result if execution.timed_out
|
|
345
|
+
return execution.file_error if execution.file_error
|
|
346
|
+
return pass_result if !execution.tests.empty? && execution.tests.values.all? { |test| test["status"] == "pass" }
|
|
347
|
+
return pass_result if execution.tests.empty? && execution.exit_code.zero?
|
|
348
|
+
|
|
349
|
+
values = execution.tests.values
|
|
350
|
+
failed = values.find { |test| test["status"] != "pass" }
|
|
351
|
+
failed || Failure.result("error", "RuntimeError", "no tests ran", [])
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def build_row(relative, name, line, base_result, head_result, mentions)
|
|
355
|
+
base_result = missing_result if base_result.nil?
|
|
356
|
+
head_result = missing_result if head_result.nil?
|
|
357
|
+
{
|
|
358
|
+
"file" => relative.tr("\\", "/"),
|
|
359
|
+
"name" => name,
|
|
360
|
+
"line" => line,
|
|
361
|
+
"mentions" => mentions,
|
|
362
|
+
"classification" => classify(base_result, head_result),
|
|
363
|
+
"base" => base_result,
|
|
364
|
+
"head" => head_result
|
|
365
|
+
}
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A base that timed out may pass with more time, so a head that did not
|
|
369
|
+
# pass is a candidate the assessor discards, not a test that failed on
|
|
370
|
+
# base.
|
|
371
|
+
def classify(base_result, head_result)
|
|
372
|
+
base_pass = base_result["status"] == "pass"
|
|
373
|
+
head_status = head_result["status"]
|
|
374
|
+
if (base_pass || base_result["status"] == "timeout") && %w[fail error timeout].include?(head_status)
|
|
375
|
+
"candidate"
|
|
376
|
+
elsif base_pass && %w[pass skip].include?(head_status)
|
|
377
|
+
"no_signal"
|
|
378
|
+
else
|
|
379
|
+
"fail_on_base"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# The whole message is compared: an assertion's first line names the
|
|
384
|
+
# expected value, and the value that moves between runs is on a later line.
|
|
385
|
+
def failure_key(result)
|
|
386
|
+
lines = result["message"].to_s.lines.map(&:strip).reject(&:empty?).join("\n")
|
|
387
|
+
message = Text.mask_times(Text.mask_addresses(temp_roots.reduce(lines) { |text, root| Text.mask_temp_names(text, root) }))
|
|
388
|
+
[result["status"], result["error_class"].to_s, message].join("\x1f")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def outcome_key(result)
|
|
392
|
+
[result["status"], result["error_class"].to_s]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# A symlinked temp directory (macOS /tmp) can appear under either name.
|
|
396
|
+
def temp_roots
|
|
397
|
+
@temp_roots ||= [Dir.tmpdir, File.realpath(Dir.tmpdir)].uniq.sort_by { |root| -root.length }
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def execute(worktree, relative, normalizer)
|
|
401
|
+
framework = @frameworks.fetch(relative)
|
|
402
|
+
stdout_path = File.join(worktree, ".jitcatch-stdout")
|
|
403
|
+
stderr_path = File.join(worktree, ".jitcatch-stderr")
|
|
404
|
+
report_path = File.join(worktree, Framework::REPORT)
|
|
405
|
+
FileUtils.rm_f(report_path)
|
|
406
|
+
cmd = [
|
|
407
|
+
RbConfig.ruby,
|
|
408
|
+
*("-rbundler/setup" if gemfile?(worktree)),
|
|
409
|
+
"-I#{File.join(worktree, "lib")}",
|
|
410
|
+
"-I#{worktree}",
|
|
411
|
+
*framework.arguments(File.join(".jitcatch", relative))
|
|
412
|
+
]
|
|
413
|
+
status, stdout, stderr, timed_out, seconds = run_process(
|
|
414
|
+
cmd,
|
|
415
|
+
chdir: worktree,
|
|
416
|
+
stdout_path: stdout_path,
|
|
417
|
+
stderr_path: stderr_path,
|
|
418
|
+
env: child_env(worktree)
|
|
419
|
+
)
|
|
420
|
+
if timed_out
|
|
421
|
+
return Result.new(tests: {}, lines: {}, file_error: nil, timed_out: true, exit_code: nil, seconds: seconds)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
parsed = framework.parse(File.file?(report_path) ? read_text(report_path) : nil, stdout, stderr, normalizer)
|
|
425
|
+
Result.new(
|
|
426
|
+
tests: parsed["tests"].transform_values { |result| Failure.naming_tests(result, tests_from_root) },
|
|
427
|
+
lines: parsed["lines"],
|
|
428
|
+
file_error: Failure.naming_tests(parsed["file_error"], tests_from_root),
|
|
429
|
+
timed_out: false,
|
|
430
|
+
exit_code: status&.exitstatus,
|
|
431
|
+
seconds: seconds
|
|
432
|
+
)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def run_process(cmd, chdir:, stdout_path:, stderr_path:, env:)
|
|
436
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
437
|
+
pid = Process.spawn(env, *cmd, chdir: chdir, out: stdout_path, err: stderr_path, pgroup: true, unsetenv_others: true)
|
|
438
|
+
waiter = Process.detach(pid)
|
|
439
|
+
timed_out = !@finished_in_time.call(waiter)
|
|
440
|
+
seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
441
|
+
kill_group(pid) if timed_out
|
|
442
|
+
status = waiter.value
|
|
443
|
+
[status, read_text(stdout_path), read_text(stderr_path), timed_out, seconds]
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def kill_group(pid)
|
|
447
|
+
Process.kill("-TERM", pid)
|
|
448
|
+
rescue Errno::ESRCH
|
|
449
|
+
nil
|
|
450
|
+
else
|
|
451
|
+
sleep 0.1
|
|
452
|
+
begin
|
|
453
|
+
Process.kill("-KILL", pid)
|
|
454
|
+
rescue Errno::ESRCH
|
|
455
|
+
nil
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def read_text(path)
|
|
460
|
+
return "" unless File.file?(path)
|
|
461
|
+
|
|
462
|
+
text = File.read(path, mode: "rb")
|
|
463
|
+
text.force_encoding(Encoding::UTF_8)
|
|
464
|
+
text.valid_encoding? ? text : text.scrub("?")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def child_env(worktree)
|
|
468
|
+
env = (PASSED_ENV + @env_names).to_h { |key| [key, ENV[key]] }.compact
|
|
469
|
+
env["BUNDLE_GEMFILE"] = File.join(worktree, "Gemfile") if gemfile?(worktree)
|
|
470
|
+
env["TEST_ENV_NUMBER"] = @test_env_numbers.fetch(worktree)
|
|
471
|
+
env["DATABASE_URL"] = numbered_database_url(env["DATABASE_URL"], env["TEST_ENV_NUMBER"]) if @number_database_url
|
|
472
|
+
env.merge(FIXED_ENV)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def gemfile?(worktree)
|
|
476
|
+
File.file?(File.join(worktree, "Gemfile"))
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# A database server that base and head reach under one name is one
|
|
480
|
+
# database, into which each side loads its own schema while the other
|
|
481
|
+
# runs; an SQLite file at a relative path is a file of each worktree.
|
|
482
|
+
# Nil when they share none, or when a side's config/database.yml does
|
|
483
|
+
# not read without booting the app.
|
|
484
|
+
def shared_test_database(pair)
|
|
485
|
+
base = Thread.new { test_databases(pair.base) }
|
|
486
|
+
head = test_databases(pair.head)
|
|
487
|
+
(base.value.to_a & head.to_a).first&.last
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def test_databases(worktree)
|
|
491
|
+
return nil unless File.file?(File.join(worktree, "config", "database.yml"))
|
|
492
|
+
|
|
493
|
+
script = [*(BUNDLE_SETUP if gemfile?(worktree)), TEST_DATABASES].join("\n")
|
|
494
|
+
out, status = Open3.capture2(child_env(worktree), RbConfig.ruby, "-e", script, chdir: worktree, unsetenv_others: true, err: File::NULL)
|
|
495
|
+
return nil unless status.success?
|
|
496
|
+
|
|
497
|
+
JSON.parse(out).filter_map do |config|
|
|
498
|
+
name = config["database"]
|
|
499
|
+
next [[config["adapter"], config["host"], config["port"], config["socket"], name], name] unless config["adapter"].include?("sqlite")
|
|
500
|
+
|
|
501
|
+
full = File.expand_path(name, worktree)
|
|
502
|
+
[[full], full]
|
|
503
|
+
end
|
|
504
|
+
rescue JSON::ParserError
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The names to create, without the rest of the URL, which can hold a
|
|
509
|
+
# password.
|
|
510
|
+
def numbered_databases
|
|
511
|
+
return [] unless @number_database_url
|
|
512
|
+
|
|
513
|
+
(1..2 * @pair_count).map { |number| URI.parse(numbered_database_url(ENV["DATABASE_URL"], number.to_s)).path.delete_prefix("/") }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# One line says what happened, and one says how to run both sides at
|
|
517
|
+
# once. Rails reads a DATABASE_URL that names a database over the
|
|
518
|
+
# `database:` of database.yml, so then only the URL can split it, and
|
|
519
|
+
# one that names none takes `database:` from the file. A past commit's
|
|
520
|
+
# database.yml cannot change, so for it the run one process at a time is
|
|
521
|
+
# the one to keep.
|
|
522
|
+
def shared_database_warnings(name)
|
|
523
|
+
numbers = @test_env_numbers.values.sort_by(&:to_i)
|
|
524
|
+
happened = "base and head use one test database, #{name}, so jitcatch ran one process at a time; that changes how long the run takes, not its results."
|
|
525
|
+
url = ENV["DATABASE_URL"] if @env_names.include?("DATABASE_URL")
|
|
526
|
+
remedy = if url && numbered_database_url(url, "1")
|
|
527
|
+
"To run them at once, add --number-database-url: each checkout then adds its TEST_ENV_NUMBER to the database name in DATABASE_URL " \
|
|
528
|
+
"(#{numbers.map { |number| "#{name}_#{number}" }.join(", ")}), and you create those databases once. " \
|
|
529
|
+
"Numbering database: in config/database.yml does not split it, because a DATABASE_URL that names a database wins over database:."
|
|
530
|
+
elsif sqlite_url?(url)
|
|
531
|
+
"To run them at once, give DATABASE_URL a #{SQLITE_RELATIVE}. --number-database-url numbers only a database server's database."
|
|
532
|
+
else
|
|
533
|
+
"To run them at once, end the test database name in config/database.yml with <%= ENV[\"TEST_ENV_NUMBER\"] %> " \
|
|
534
|
+
"and create the numbered databases: for n in #{numbers.join(" ")}; do RAILS_ENV=test TEST_ENV_NUMBER=$n bin/rails db:create; done. " \
|
|
535
|
+
"This helps only when BASE and HEAD both have that line."
|
|
536
|
+
end
|
|
537
|
+
[happened, remedy]
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# One check per side, so a side that cannot load its bundle or a
|
|
541
|
+
# framework is said once, as the setup failure it is, instead of as a
|
|
542
|
+
# load error on every candidate file.
|
|
543
|
+
def setup_status(side, commit, worktree, normalizer, frameworks)
|
|
544
|
+
bundled = gemfile?(worktree)
|
|
545
|
+
bundle = bundled ? "ok" : "none"
|
|
546
|
+
# Every framework is tried, so one run names all that are missing.
|
|
547
|
+
checks = frameworks.map { |framework|
|
|
548
|
+
"begin; require #{framework::LIBRARY.inspect}; rescue LoadError; $stderr.puts #{framework.missing(bundled).inspect}; missing = true; end"
|
|
549
|
+
}
|
|
550
|
+
status, _stdout, stderr, timed_out = run_process(
|
|
551
|
+
[RbConfig.ruby, "-e", [*(BUNDLE_SETUP if bundled), "missing = false", *checks, "exit 1 if missing"].join("; ")],
|
|
552
|
+
chdir: worktree,
|
|
553
|
+
stdout_path: File.join(worktree, ".jitcatch-stdout"),
|
|
554
|
+
stderr_path: File.join(worktree, ".jitcatch-stderr"),
|
|
555
|
+
env: child_env(worktree)
|
|
556
|
+
)
|
|
557
|
+
return { "bundle" => bundle, "failure" => nil } if !timed_out && status&.success?
|
|
558
|
+
|
|
559
|
+
requires = [*("bundler/setup" if bundled), *frameworks.map { |framework| framework::LIBRARY }]
|
|
560
|
+
tail = Text.message_lines(normalizer.call(stderr)).last(SETUP_STDERR_LINES)
|
|
561
|
+
failure = {
|
|
562
|
+
"side" => side,
|
|
563
|
+
"commit" => commit,
|
|
564
|
+
"command" => "ruby -e #{Framework::Minitest.quote(requires.map { |library| %(require "#{library}") }.join("; "))}",
|
|
565
|
+
"stderr" => timed_out ? ["timed out after #{@timeout}s"] : tail,
|
|
566
|
+
"not_passed" => bundled ? unpassed_bundler_settings : []
|
|
567
|
+
}
|
|
568
|
+
{ "bundle" => bundle, "failure" => failure }
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Every checkout, so a worker's pair holds the same schemas as the
|
|
572
|
+
# first. One at a time: two loads into one shared database would race.
|
|
573
|
+
# RAILS_ENV is test unless --env passes one, so a schema load never
|
|
574
|
+
# reaches the development database.
|
|
575
|
+
def run_setup(pairs, change, normalizer)
|
|
576
|
+
return [] unless @setup
|
|
577
|
+
|
|
578
|
+
pairs.flat_map { |pair| [["base", pair.base], ["head", pair.head]] }.filter_map do |side, worktree|
|
|
579
|
+
env = { "RAILS_ENV" => "test" }.merge(child_env(worktree))
|
|
580
|
+
status, _stdout, stderr, timed_out = run_process(
|
|
581
|
+
["/bin/sh", "-c", @setup],
|
|
582
|
+
chdir: worktree,
|
|
583
|
+
stdout_path: File.join(worktree, ".jitcatch-stdout"),
|
|
584
|
+
stderr_path: File.join(worktree, ".jitcatch-stderr"),
|
|
585
|
+
env: env
|
|
586
|
+
)
|
|
587
|
+
next if !timed_out && status&.success?
|
|
588
|
+
|
|
589
|
+
lines = Text.message_lines(normalizer.call(stderr))
|
|
590
|
+
{
|
|
591
|
+
"side" => side,
|
|
592
|
+
"commit" => change[side],
|
|
593
|
+
"command" => @setup,
|
|
594
|
+
"stderr" => timed_out ? ["timed out after #{@timeout}s"] : lines.last(SETUP_STDERR_LINES),
|
|
595
|
+
"not_passed" => [],
|
|
596
|
+
"database_not_passed" => timed_out ? [] : database_not_passed(lines)
|
|
597
|
+
}
|
|
598
|
+
end.uniq { |failure| failure["side"] }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# A side that loads its bundle can still fail to boot what every
|
|
602
|
+
# candidate requires, such as a Rails app whose database does not exist.
|
|
603
|
+
# When no file loaded on either side, no row can tell base from head.
|
|
604
|
+
def load_failure_doc(change, rows, bundled, rails_app)
|
|
605
|
+
return nil if rows.empty?
|
|
606
|
+
return nil unless rows.all? { |row| load_row?(row) && row["base"]["status"] == "error" && row["head"]["status"] == "error" }
|
|
607
|
+
|
|
608
|
+
first = rows.min_by { |row| row["file"] }
|
|
609
|
+
failed = %w[base head].map { |side|
|
|
610
|
+
result = first[side]
|
|
611
|
+
lines = Text.message_lines(result["message"].to_s)
|
|
612
|
+
# Ruby's own report of an uncaught error already ends in its class.
|
|
613
|
+
lines[0] = "#{result["error_class"]}: #{lines[0]}" unless lines.empty? || lines[0].match?(/\([A-Z][\w:]*\)\z/)
|
|
614
|
+
{
|
|
615
|
+
"side" => side,
|
|
616
|
+
"commit" => change[side],
|
|
617
|
+
"command" => first["rerun"],
|
|
618
|
+
"stderr" => lines.first(SETUP_STDERR_LINES),
|
|
619
|
+
"not_passed" => bundled ? unpassed_bundler_settings : [],
|
|
620
|
+
"database_not_passed" => database_not_passed(lines)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
setup_failed_doc(change, failed, stage: "load", rails_app: rails_app)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# rails_app: the head has config/application.rb, so a credential that
|
|
627
|
+
# git ignores, such as config/master.key, is a likely cause.
|
|
628
|
+
def setup_failed_doc(change, failed, rails_app:, stage: "setup")
|
|
629
|
+
{
|
|
630
|
+
"version" => VERSION,
|
|
631
|
+
"command" => "run",
|
|
632
|
+
"error" => "setup_failed",
|
|
633
|
+
"stage" => stage,
|
|
634
|
+
"rails_app" => rails_app,
|
|
635
|
+
# A bundle installed for another Ruby reads as gems not installed.
|
|
636
|
+
"ruby" => { "version" => RUBY_VERSION, "path" => RbConfig.ruby },
|
|
637
|
+
"base" => change["base"],
|
|
638
|
+
"head" => change["head"],
|
|
639
|
+
"setup_failed" => failed
|
|
640
|
+
}
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Bundler reads settings such as BUNDLE_WITHOUT from the environment,
|
|
644
|
+
# and the child gets only the names --env passes. `bundle exec` sets the
|
|
645
|
+
# Gemfile and lockfile names for jitcatch's own bundle, not the checkout's.
|
|
646
|
+
# Names the parent has and the child does not get that a child often
|
|
647
|
+
# waits on: names the head's Ruby source or a candidate reads from ENV,
|
|
648
|
+
# and network settings when either loads an HTTP client.
|
|
649
|
+
def env_not_passed(head, files)
|
|
650
|
+
candidates = files.map { |relative| read_candidate(relative) }
|
|
651
|
+
read = [@git.ruby_matches(head, ENV_READ_GREP), *candidates].flat_map { |text| text.scan(ENV_READ).flatten }
|
|
652
|
+
network = network_library?(head, candidates)
|
|
653
|
+
passed = PASSED_ENV + RESERVED_ENV + @env_names
|
|
654
|
+
ENV.keys.select { |name| (network && name.match?(NETWORK_ENV)) || read.include?(name) }.reject { |name| passed.include?(name) }.sort
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def network_library?(head, candidates)
|
|
658
|
+
locked = @git.show(head, "Gemfile.lock").to_s.scan(/^ (\S+) \(/).flatten
|
|
659
|
+
(locked & NETWORK_GEMS).any? || [@git.ruby_matches(head, NETWORK_USE_GREP), *candidates].any? { |text| text.match?(NETWORK_USE) }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def database_not_passed(lines)
|
|
663
|
+
return [] unless lines.any? { |line| line.match?(DATABASE_REFUSED) }
|
|
664
|
+
|
|
665
|
+
ENV.keys.grep(DATABASE_ENV).sort - @env_names
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def unpassed_bundler_settings
|
|
669
|
+
ENV.keys.grep(/\ABUNDLE_/).sort - %w[BUNDLE_BIN_PATH BUNDLE_GEMFILE BUNDLE_LOCKFILE] - @env_names
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# `bundle config set --local` writes .bundle/config, which git does not
|
|
673
|
+
# track, so a worktree would load its bundle without the checkout's
|
|
674
|
+
# settings, such as BUNDLE_WITHOUT. A relative BUNDLE_PATH names a
|
|
675
|
+
# directory of the checkout, so it is written out from there.
|
|
676
|
+
def copy_bundler_settings(worktree)
|
|
677
|
+
source = File.join(@git.dir, ".bundle", "config")
|
|
678
|
+
target = File.join(worktree, ".bundle", "config")
|
|
679
|
+
return unless gemfile?(worktree) && File.file?(source) && !File.exist?(target)
|
|
680
|
+
|
|
681
|
+
text = File.read(source).gsub(/^BUNDLE_PATH: "?([^"\n]*)"?$/) { %(BUNDLE_PATH: "#{File.expand_path(Regexp.last_match(1), @git.dir)}") }
|
|
682
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
683
|
+
File.write(target, text)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def install(worktree)
|
|
687
|
+
dest = File.join(worktree, ".jitcatch")
|
|
688
|
+
FileUtils.rm_rf(dest)
|
|
689
|
+
FileUtils.mkdir_p(dest)
|
|
690
|
+
Dir.children(@tests_dir).sort.each do |child|
|
|
691
|
+
FileUtils.cp_r(File.join(@tests_dir, child), dest)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def read_candidate(relative)
|
|
696
|
+
File.read(File.join(@tests_dir, relative), mode: "r:bom|utf-8")
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def candidate_files
|
|
700
|
+
all = Dir.glob("**/*.rb", base: @tests_dir).sort
|
|
701
|
+
named = all.select { |path| Framework.named?(path) }
|
|
702
|
+
list = named.empty? ? all : named
|
|
703
|
+
omitted = 0
|
|
704
|
+
if list.size > FILE_CAP
|
|
705
|
+
omitted = list.size - FILE_CAP
|
|
706
|
+
list = list.first(FILE_CAP)
|
|
707
|
+
end
|
|
708
|
+
[list, omitted]
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# The names a test can mention. A test runs `Owner#initialize` by writing
|
|
712
|
+
# `Owner.new`, so a constructor is mentioned as `Owner.new`, with the
|
|
713
|
+
# owner's last name segment, the shortest way a test can write it, and
|
|
714
|
+
# so is a record builder or a save that runs a changed callback.
|
|
715
|
+
def mention_names(change)
|
|
716
|
+
names = change["methods"].flat_map { |method|
|
|
717
|
+
[method["name"], *method["reached_by"], *method["dynamic_callers"].map { |caller| caller["from"] }].map { |name| Assessor.mention_of(name) } +
|
|
718
|
+
(Array(method["builders"]) + Array(method["run_by"])).flat_map { |name| [name, name.sub(/\A(?:\w+::)+/, "")] }
|
|
719
|
+
}
|
|
720
|
+
names += change["entrypoints"].flat_map { |entry| entry["methods"].map { |method| Assessor.mention_of(method["name"]) } }
|
|
721
|
+
names += change["constants"].map { |const| const["bare_name"] }
|
|
722
|
+
names.compact.uniq.sort.reject(&:empty?)
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Mentions are taken from the test method and the same-file helpers it
|
|
726
|
+
# calls, so a neighbor test in the same file does not change its tags.
|
|
727
|
+
def mentions_for(index, test_name, line, known, relative, head_dir)
|
|
728
|
+
referenced = (line && index.example_references(line)) || index.referenced_names(test_name)
|
|
729
|
+
referenced -= foreign_calls(index, test_name, line, known.constants)
|
|
730
|
+
referenced += script_names(index.paths_named(test_name, line), relative, head_dir)
|
|
731
|
+
names = known.names
|
|
732
|
+
# A request names the path it sends, which an endpoint's names only
|
|
733
|
+
# through a pattern, so each one becomes the endpoints it reaches, and
|
|
734
|
+
# the controller action the routes draw for it.
|
|
735
|
+
endpoints = names.select { |name| SourceIndex.endpoint?(name) }
|
|
736
|
+
referenced = referenced.flat_map { |name|
|
|
737
|
+
next [name] unless SourceIndex.request?(name)
|
|
738
|
+
|
|
739
|
+
actions = known.routes.actions(name)
|
|
740
|
+
[name, *endpoints.select { |endpoint| SourceIndex.endpoint_call?(name, endpoint) }, *actions, *actions.map { |action| SourceIndex.split_qualified(action).last }]
|
|
741
|
+
}.uniq
|
|
742
|
+
referenced.select { |name|
|
|
743
|
+
next false if known.unsaved.key?(name) && !SourceIndex.saves_or_validates?(referenced, known.unsaved[name])
|
|
744
|
+
|
|
745
|
+
names.include?(name) || names.include?(SourceIndex.split_qualified(name).last) || names.include?(Assessor.mention_of(name))
|
|
746
|
+
}
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# A `build(:loan)` that builds a changed class runs its constructor
|
|
750
|
+
# whatever the test does next, so only one that no method lists as a
|
|
751
|
+
# builder waits for a save or a validation.
|
|
752
|
+
def unsaved_mentions(change)
|
|
753
|
+
builders = change["methods"].flat_map { |method| Array(method["builders"]) }
|
|
754
|
+
change["methods"].each_with_object(Hash.new { |hash, key| hash[key] = [] }) { |method, found|
|
|
755
|
+
run_by = Array(method["run_by"])
|
|
756
|
+
run_by.select { |name| SourceIndex.unsaved_mention?(name) && !builders.include?(name) }
|
|
757
|
+
.each { |name| found[name] |= SourceIndex.record_calls(run_by) }
|
|
758
|
+
}.to_h
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# `File.write` reaches no method of the repository, so neither it nor
|
|
762
|
+
# the bare `write` it refers to is a mention of `Emitter.write`. A
|
|
763
|
+
# constant counts as the repository's when one it defines ends in it.
|
|
764
|
+
def foreign_calls(index, test_name, line, constants)
|
|
765
|
+
index.names_sent_only_to_constants(test_name, line).flat_map { |bare, receivers|
|
|
766
|
+
next [] if receivers.any? { |const| constants.any? { |name| name == const || name.end_with?("::#{const}") } }
|
|
767
|
+
|
|
768
|
+
[bare, *receivers.map { |const| "#{const}.#{bare}" }]
|
|
769
|
+
}
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# A test that starts a script in another process (`IO.popen(["ruby",
|
|
773
|
+
# "exe/tool"])`) reaches the code that script calls, which no call in
|
|
774
|
+
# the test names. A path the test writes, read from the worktree root,
|
|
775
|
+
# from the candidate's directory, or from the candidate file as
|
|
776
|
+
# `File.expand_path` reads it, names such a script when it is a file of
|
|
777
|
+
# head outside `.jitcatch/`; the names it refers to count as mentions.
|
|
778
|
+
def script_names(paths, relative, head_dir)
|
|
779
|
+
candidate = File.join(head_dir, ".jitcatch", relative)
|
|
780
|
+
root = File.join(head_dir, "")
|
|
781
|
+
own = File.join(head_dir, ".jitcatch", "")
|
|
782
|
+
paths.flat_map { |path|
|
|
783
|
+
[head_dir, File.dirname(candidate), candidate].map { |dir| File.expand_path(path, dir) }
|
|
784
|
+
.select { |full| full.start_with?(root) && !full.start_with?(own) && File.file?(full) }
|
|
785
|
+
}.uniq.flat_map { |full|
|
|
786
|
+
script = SourceIndex.parse(read_text(full), file: full.delete_prefix(root))
|
|
787
|
+
script.parse_error ? [] : script.referenced_names("")
|
|
788
|
+
}
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# The run loads each candidate from `.jitcatch/` at a checkout's root,
|
|
792
|
+
# and the rerun line from TESTS_DIR, so a require_relative that leaves
|
|
793
|
+
# TESTS_DIR resolves to two files unless TESTS_DIR sits at the root.
|
|
794
|
+
# A helper that config/routes.rb draws through `draw`, a loop, or a
|
|
795
|
+
# concern is not read, so a request to it ties its row to no action.
|
|
796
|
+
def route_helper_warnings(files, routes)
|
|
797
|
+
helpers = files.flat_map { |relative|
|
|
798
|
+
SourceIndex.parse(read_candidate(relative), file: relative).referenced_names(nil)
|
|
799
|
+
.select { |name| SourceIndex.route_helper_request?(name) }.map { |name| name.split(" ", 2).last }
|
|
800
|
+
}.uniq.sort.reject { |helper| routes.helper?(helper) }
|
|
801
|
+
return [] if helpers.empty?
|
|
802
|
+
|
|
803
|
+
requests = helpers.size == 1 ? "1 candidate request names a path helper" : "#{helpers.size} candidate requests name path helpers"
|
|
804
|
+
["#{requests} config/routes.rb does not draw (#{helpers.join(", ")}), so #{helpers.one? ? "it is" : "they are"} tied to no controller action."]
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def require_warnings(files)
|
|
808
|
+
tests = File.realpath(@tests_dir)
|
|
809
|
+
# From a directory directly under the root, a path that stays in
|
|
810
|
+
# TESTS_DIR and one that leaves it each name one file either way.
|
|
811
|
+
one_level = !tests_from_root.include?("/") && !%w[. ..].include?(tests_from_root)
|
|
812
|
+
files.flat_map do |relative|
|
|
813
|
+
SourceIndex.relative_requires(read_candidate(relative)).filter_map do |required|
|
|
814
|
+
if required.path.nil?
|
|
815
|
+
next if one_level
|
|
816
|
+
|
|
817
|
+
next "#{relative}:#{required.line} require_relative names a path the source does not settle, so jitcatch cannot tell whether the run and the rerun line load the same file. Load the repository's code with `require` and the rerun line's -I, or put TESTS_DIR directly under the repository root."
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
path = required.path.end_with?(".rb") ? required.path : "#{required.path}.rb"
|
|
821
|
+
in_run = Pathname(File.expand_path(path, File.dirname(File.join("/.jitcatch", relative)))).relative_path_from(Pathname("/")).to_s
|
|
822
|
+
in_rerun = from_root(File.expand_path(path, File.dirname(File.join(tests, relative))))
|
|
823
|
+
next if in_run.start_with?(".jitcatch/") || in_run == in_rerun
|
|
824
|
+
|
|
825
|
+
"#{relative}:#{required.line} require_relative #{required.path.inspect} loads #{in_run} in the run but #{in_rerun} from the rerun line, so that line may not reproduce the row. Load the repository's code with `require` and the rerun line's -I, or put TESTS_DIR directly under the repository root."
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# A file-level row (`file#load`, `file#timeout`) reruns the whole file.
|
|
831
|
+
def rerun_command(row, bundled, pin_root, launcher)
|
|
832
|
+
file_level = row["line"].nil? && row["name"].start_with?("#{File.basename(row["file"])}#")
|
|
833
|
+
name = file_level ? nil : row["name"]
|
|
834
|
+
command = @frameworks.fetch(row["file"]).rerun(File.join(tests_from_root, row["file"]), name, row["line"], pin_root: pin_root, launcher: launcher)
|
|
835
|
+
bundled ? "bundle exec #{command}" : command
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# The rerun line runs in the reader's checkout, whose Gemfile can list
|
|
839
|
+
# jitcatch while the commits under test do not, so the launcher is
|
|
840
|
+
# tried there, with that Gemfile, and not in a worktree.
|
|
841
|
+
def launcher_loads?
|
|
842
|
+
bundled = gemfile?(@git.dir)
|
|
843
|
+
env = (PASSED_ENV + @env_names).to_h { |key| [key, ENV[key]] }.compact.merge(FIXED_ENV)
|
|
844
|
+
script = [*(BUNDLE_SETUP if bundled), "require #{Framework::RSpec::LAUNCHER.inspect}"].join("; ")
|
|
845
|
+
system(env, RbConfig.ruby, "-e", script, chdir: @git.dir, unsetenv_others: true, out: File::NULL, err: File::NULL) == true
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# The rerun line runs from the repository root, so the tests directory
|
|
849
|
+
# is named by its path from there, outside the repository too: a quoted
|
|
850
|
+
# line then names no directory of one machine.
|
|
851
|
+
def tests_from_root
|
|
852
|
+
@tests_from_root ||= from_root(File.realpath(@tests_dir))
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# A path outside the repository is named from the root while that is
|
|
856
|
+
# shorter than its absolute path, as `../catch` beside the checkout is.
|
|
857
|
+
# A climb out of a checkout deep in a home directory to a directory
|
|
858
|
+
# under /tmp is longer than the absolute path, and says less.
|
|
859
|
+
def from_root(full)
|
|
860
|
+
relative = Pathname(full).relative_path_from(Pathname(File.realpath(@git.dir))).to_s
|
|
861
|
+
relative.start_with?("../") && full.length < relative.length ? full : relative
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def assign_test_ids(rows)
|
|
865
|
+
rows.sort_by! { |row| [row["file"], row["name"]] }
|
|
866
|
+
width = [2, rows.size.to_s.length].max
|
|
867
|
+
rows.each_with_index { |row, index| row["id"] = format("t%0#{width}d", index + 1) }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def slim_method(method)
|
|
871
|
+
method.reject { |key, _| %w[old_source new_source callsites existing_tests entry_point_tests].include?(key) }
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def slim_constant(const)
|
|
875
|
+
const.reject { |key, _| %w[old_source new_source].include?(key) }
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def build_normalizer(roots)
|
|
879
|
+
prefix = RbConfig::CONFIG["prefix"].to_s.sub(%r{/+\z}, "")
|
|
880
|
+
ordered = roots.compact.map { |root| File.expand_path(root).sub(%r{/+\z}, "") }.uniq.sort_by { |item| -item.length }
|
|
881
|
+
# A root can end the path, as `Dir.pwd` does, but `head` must not
|
|
882
|
+
# match the start of `head2`.
|
|
883
|
+
patterns = ordered.map { |root| /#{Regexp.escape(root)}(?![\w.-])/ }
|
|
884
|
+
root = Failure::WORKTREE.chomp("/")
|
|
885
|
+
lambda do |text|
|
|
886
|
+
out = text.to_s.gsub("\r\n", "\n")
|
|
887
|
+
patterns.each { |pattern| out = out.gsub(pattern, root) }
|
|
888
|
+
out = out.gsub("#{prefix}/", "{ruby}/") unless prefix.empty?
|
|
889
|
+
out
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def pass_result
|
|
894
|
+
Failure.passed("pass")
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def missing_result
|
|
898
|
+
Failure.result("error", "Missing", "test did not run", [])
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def timeout_result
|
|
902
|
+
Failure.result("timeout", "Timeout", "timeout", [])
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
end
|