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.
@@ -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