Sam Ford cb514a1d04
Disable parallel for small test coverage runs
`brew tests --coverage` can fail to produce coverage information when
run on a small number of tests (e.g., `--only utils/curl`). We use
`ParallelTests::last_process?` in `tests/spec_helper.rb` to handle
the SimpleCov output but due to the way the method is implemented, it
doesn't work as expected if the number of processes is greater than
one but lower than the number of cores. I tried to address this
through changes to  `spec_helper.rb` and/or changes to `ParallelTests`
but didn't meet with any success.

This works around the issue by disabling parallel test execution when
the `--coverage` option is used and the number of files to be tested
is lower than the number of CPU cores. I've been using this workaround
for months and it works as expected on my machine.
2025-01-10 19:42:04 -05:00

248 lines
9.0 KiB
Ruby

# typed: strict
# frozen_string_literal: true
require "abstract_command"
require "fileutils"
require "hardware"
require "system_command"
module Homebrew
module DevCmd
class Tests < AbstractCommand
include SystemCommand::Mixin
cmd_args do
description <<~EOS
Run Homebrew's unit and integration tests.
EOS
switch "--coverage",
description: "Generate code coverage reports."
switch "--generic",
description: "Run only OS-agnostic tests."
switch "--online",
description: "Include tests that use the GitHub API and tests that use any of the taps for " \
"official external commands."
switch "--debug",
description: "Enable debugging using `ruby/debug`, or surface the standard `odebug` output."
switch "--changed",
description: "Only runs tests on files that were changed from the master branch."
switch "--fail-fast",
description: "Exit early on the first failing test."
flag "--only=",
description: "Run only `<test_script>_spec.rb`. Appending `:<line_number>` will start at a " \
"specific line."
flag "--profile=",
description: "Run the test suite serially to find the <n> slowest tests."
flag "--seed=",
description: "Randomise tests with the specified <value> instead of a random seed."
conflicts "--changed", "--only"
named_args :none
end
sig { override.void }
def run
# Given we might be testing various commands, we probably want everything (except sorbet-static)
Homebrew.install_bundler_gems!(groups: Homebrew.valid_gem_groups - ["sorbet"])
HOMEBREW_LIBRARY_PATH.cd do
setup_environment!
parallel = true
only = args.only
files = if only
test_name, line = only.split(":", 2)
if line.nil?
Dir.glob("test/{#{test_name},#{test_name}/**/*}_spec.rb")
else
parallel = false
["test/#{test_name}_spec.rb:#{line}"]
end
elsif args.changed?
changed_test_files
else
Dir.glob("test/**/*_spec.rb")
end
if files.blank?
raise UsageError, "The `--only` argument requires a valid file or folder name!" if only
if args.changed?
opoo "No tests are directly associated with the changed files!"
return
end
end
# We use `ParallelTests.last_process?` in `test/spec_helper.rb` to
# handle SimpleCov output but, due to how the method is implemented,
# it doesn't work as expected if the number of processes is greater
# than one but lower than the number of CPU cores in the execution
# environment. Coverage information isn't saved in that scenario,
# so we disable parallel testing as a workaround in this case.
parallel = false if args.profile || (args.coverage? && files.length < Hardware::CPU.cores)
parallel_rspec_log_name = "parallel_runtime_rspec"
parallel_rspec_log_name = "#{parallel_rspec_log_name}.generic" if args.generic?
parallel_rspec_log_name = "#{parallel_rspec_log_name}.online" if args.online?
parallel_rspec_log_name = "#{parallel_rspec_log_name}.log"
parallel_rspec_log_path = if ENV["CI"]
"tests/#{parallel_rspec_log_name}"
else
"#{HOMEBREW_CACHE}/#{parallel_rspec_log_name}"
end
ENV["PARALLEL_RSPEC_LOG_PATH"] = parallel_rspec_log_path
parallel_args = if ENV["CI"]
%W[
--combine-stderr
--serialize-stdout
--runtime-log #{parallel_rspec_log_path}
]
else
%w[
--nice
]
end
# Generate seed ourselves and output later to avoid multiple different
# seeds being output when running parallel tests.
seed = args.seed || rand(0xFFFF).to_i
bundle_args = ["-I", HOMEBREW_LIBRARY_PATH/"test"]
bundle_args += %W[
--seed #{seed}
--color
--require spec_helper
]
bundle_args << "--fail-fast" if args.fail_fast?
bundle_args << "--profile" << args.profile if args.profile
# TODO: Refactor and move to extend/os
# rubocop:disable Homebrew/MoveToExtendOS
unless OS.mac?
bundle_args << "--tag" << "~needs_macos" << "--tag" << "~cask"
files = files.grep_v(%r{^test/(os/mac|cask)(/.*|_spec\.rb)$})
end
unless OS.linux?
bundle_args << "--tag" << "~needs_linux"
files = files.grep_v(%r{^test/os/linux(/.*|_spec\.rb)$})
end
# rubocop:enable Homebrew/MoveToExtendOS
bundle_args << "--tag" << "~needs_network" unless args.online?
unless ENV["CI"]
bundle_args << "--tag" << "~needs_ci" \
<< "--tag" << "~needs_svn"
end
puts "Randomized with seed #{seed}"
ENV["HOMEBREW_DEBUG"] = "1" if args.debug? # Used in spec_helper.rb to require the "debug" gem.
# Workaround for:
#
# ```
# ruby: no -r allowed while running setuid (SecurityError)
# ```
Process::UID.change_privilege(Process.euid) if Process.euid != Process.uid
if parallel
system "bundle", "exec", "parallel_rspec", *parallel_args, "--", *bundle_args, "--", *files
else
system "bundle", "exec", "rspec", *bundle_args, "--", *files
end
success = $CHILD_STATUS.success?
return if success
Homebrew.failed = true
end
end
private
sig { returns(T::Array[String]) }
def changed_test_files
changed_files = Utils.popen_read("git", "diff", "--name-only", "master")
raise UsageError, "No files have been changed from the master branch!" if changed_files.blank?
filestub_regex = %r{Library/Homebrew/([\w/-]+).rb}
changed_files.scan(filestub_regex).map(&:last).filter_map do |filestub|
if filestub.start_with?("test/")
# Only run tests on *_spec.rb files in test/ folder
filestub.end_with?("_spec") ? Pathname("#{filestub}.rb") : nil
else
# For all other changed .rb files guess the associated test file name
Pathname("test/#{filestub}_spec.rb")
end
end.select(&:exist?)
end
sig { returns(T::Array[String]) }
def setup_environment!
# Cleanup any unwanted user configuration.
allowed_test_env = %w[
HOMEBREW_GITHUB_API_TOKEN
HOMEBREW_CACHE
HOMEBREW_LOGS
HOMEBREW_TEMP
]
allowed_test_env << "HOMEBREW_USE_RUBY_FROM_PATH" if Homebrew::EnvConfig.developer?
Homebrew::EnvConfig::ENVS.keys.map(&:to_s).each do |env|
next if allowed_test_env.include?(env)
ENV.delete(env)
end
# Codespaces HOMEBREW_PREFIX and /tmp are mounted 755 which makes Ruby warn constantly.
if (ENV["HOMEBREW_CODESPACES"] == "true") && (HOMEBREW_TEMP.to_s == "/tmp")
# Need to keep this fairly short to avoid socket paths being too long in tests.
homebrew_prefix_tmp = "/home/linuxbrew/tmp"
ENV["HOMEBREW_TEMP"] = homebrew_prefix_tmp
FileUtils.mkdir_p homebrew_prefix_tmp
system "chmod", "-R", "g-w,o-w", HOMEBREW_PREFIX, homebrew_prefix_tmp
end
ENV["HOMEBREW_TESTS"] = "1"
ENV["HOMEBREW_NO_AUTO_UPDATE"] = "1"
ENV["HOMEBREW_NO_ANALYTICS_THIS_RUN"] = "1"
ENV["HOMEBREW_TEST_GENERIC_OS"] = "1" if args.generic?
ENV["HOMEBREW_TEST_ONLINE"] = "1" if args.online?
ENV["HOMEBREW_SORBET_RUNTIME"] = "1"
# TODO: remove this and fix tests when possible.
ENV["HOMEBREW_NO_INSTALL_FROM_API"] = "1"
ENV.delete("HOMEBREW_INTERNAL_JSON_V3")
ENV["USER"] ||= system_command!("id", args: ["-nu"]).stdout.chomp
# Avoid local configuration messing with tests, e.g. git being configured
# to use GPG to sign by default
ENV["HOME"] = "#{HOMEBREW_LIBRARY_PATH}/test"
# Print verbose output when requesting debug or verbose output.
ENV["HOMEBREW_VERBOSE_TESTS"] = "1" if args.debug? || args.verbose?
if args.coverage?
ENV["HOMEBREW_TESTS_COVERAGE"] = "1"
FileUtils.rm_f "test/coverage/.resultset.json"
end
# Override author/committer as global settings might be invalid and thus
# will cause silent failure during the setup of dummy Git repositories.
%w[AUTHOR COMMITTER].each do |role|
ENV["GIT_#{role}_NAME"] = "brew tests"
ENV["GIT_#{role}_EMAIL"] = "brew-tests@localhost"
ENV["GIT_#{role}_DATE"] = "Sun Jan 22 19:59:13 2017 +0000"
end
end
end
end
end