diff --git a/test/test_helper.rb b/test/test_helper.rb index a6baf4ee..7d43ce7c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,6 +16,33 @@ require "pathname" TEST_IMAGES_DIR = Pathname.new(File.expand_path("fixtures/images", __dir__)) +# The child environment for a subprocess probe that chdirs OUT of the project +# to exercise a load path it built itself. Such a probe must not inherit this +# repo's development bundle, and dropping these four is the whole of it: +# +# - BUNDLER_SETUP is the one that bit us. RubyGems ends rubygems.rb with +# `require ENV["BUNDLER_SETUP"] if ENV["BUNDLER_SETUP"] && !defined?(Bundler)`, +# so ANY child re-enters bundler/setup before it reaches `-e`. That resolves +# BUNDLE_GEMFILE -- which .github/workflows/test.yml sets to the RELATIVE +# `gemfiles/` -- against the child's cwd, i.e. the tmpdir, and the +# child dies with Bundler::GemfileNotFound before running a line of the probe. +# Bundler >= 2.6 rewrites BUNDLE_GEMFILE to an absolute path during setup and +# the 2.5 that ships with ruby 3.3 does not, so this was green everywhere but +# the 3.3 cells -- and invisible under `bundle exec`, which expands it too. +# - RUBYOPT carries `-r/bundler/setup`, the same door. +# - RUBYLIB and BUNDLE_GEMFILE inject the development load path directly. +# +# Scrubbing is not sufficient on its own -- the probe must ALSO chdir out of +# the project, or RubyGems finds gems.rb and puts all of it back. Probes that +# stay inside the project root (`chdir: PROJECT_ROOT`) genuinely want the +# bundle and must NOT use this. +PROBE_ENV = { + "BUNDLER_SETUP" => nil, + "BUNDLE_GEMFILE" => nil, + "RUBYLIB" => nil, + "RUBYOPT" => nil +}.freeze + require "support/setup_rails_app" require "minitest/autorun" diff --git a/test/unit/gem_name_entry_point_test.rb b/test/unit/gem_name_entry_point_test.rb index 45ae35dc..7709cda4 100644 --- a/test/unit/gem_name_entry_point_test.rb +++ b/test/unit/gem_name_entry_point_test.rb @@ -24,6 +24,13 @@ class GemNameEntryPointTest < ActiveSupport::TestCase LIB = File.expand_path("../../lib", __dir__) + # Opens every probe, ahead of the minitest gate below: the point of this + # probe is a CONSUMER's bundle, so inheriting ours proves nothing about + # either. See PROBE_ENV in test_helper.rb for what leaks and how. + NO_BUNDLE_GATE = <<~RUBY + abort("GATE: the probe inherited the development bundle, so it proves nothing") if defined?(Bundler) + RUBY + # Opens every probe: a probe that quietly tested the wrong environment # would "prove" whatever we hoped it would. GATE = { @@ -54,8 +61,12 @@ class GemNameEntryPointTest < ActiveSupport::TestCase # scratch Gemfile reproduces the same thing and was used to confirm this # one, but it needs the network and has no place in the unit suite.) # + # The cwd is half of that; PROBE_ENV is the other half, and the two only + # work together -- see its comment in test/test_helper.rb. +env+ is a seam + # for the gate test below, which restores a leak on purpose. + # # @return [Array(String, String, Process::Status)] stdout, stderr, status - def self.probe(script, minitest: true) + def self.probe(script, minitest: true, env: PROBE_ENV) Dir.mktmpdir do |dir| load_paths = ["-I#{LIB}"] @@ -68,7 +79,7 @@ def self.probe(script, minitest: true) load_paths.unshift("-I#{shim}") end - Open3.capture3(RbConfig.ruby, *load_paths, "-e", GATE.fetch(minitest) + script, chdir: dir) + Open3.capture3(env, RbConfig.ruby, *load_paths, "-e", NO_BUNDLE_GATE + GATE.fetch(minitest) + script, chdir: dir) end end @@ -98,6 +109,25 @@ def self.probe(script, minitest: true) assert_match(/require: false/, err, "the message must name the way to silence it") end + # The gate line has to be able to FAIL, or it is a comment with an `if` + # around it. Restore the ONE variable the scrub exists to drop -- RubyGems + # turns BUNDLER_SETUP back into a `require "bundler/setup"` in any child -- + # and the same probe must refuse to run. Restoring it alone (BUNDLE_GEMFILE + # still scrubbed) is what makes this deterministic on every ruby: the child + # boots far enough to reach the gate instead of dying at Bundler's own + # GemfileNotFound, which is what CI saw and which only reproduces on the + # bundler that leaves a relative BUNDLE_GEMFILE alone. + test "the no-bundle gate rejects a probe that inherited the development bundle" do + bundler_setup = ENV["BUNDLER_SETUP"] + assert bundler_setup, "this test only means something when the suite itself runs under bundler" + + out, err, status = self.class.probe(%(puts "RAN"), env: PROBE_ENV.merge("BUNDLER_SETUP" => bundler_setup)) + + refute_predicate status, :success?, "the gate let an inherited bundle through:\n#{out}\n#{err}" + assert_match(/GATE: the probe inherited the development bundle/, err) + refute_includes out, "RAN", "the gate must abort BEFORE the probe body runs" + end + # The other direction: the documented zero-require Rails path. test "the gem-name entry point still auto-activates the Minitest assertions when minitest is present" do out, err, status = self.class.probe(<<~'RUBY') diff --git a/test/unit/legacy_deletion_test.rb b/test/unit/legacy_deletion_test.rb index a11b7166..25db93b8 100644 --- a/test/unit/legacy_deletion_test.rb +++ b/test/unit/legacy_deletion_test.rb @@ -194,9 +194,9 @@ def in_deleted_tree # A fresh process with ONLY +tree+ on the load path. # - # `chdir: tree` is the load-bearing half, and NOT a detail. Scrubbing - # RUBYOPT/BUNDLE_GEMFILE is not sufficient on its own: with the cwd still - # inside the project, RubyGems auto-discovers gems.rb, puts + # `chdir: tree` is the load-bearing half, and NOT a detail. PROBE_ENV is not + # sufficient on its own: with the cwd still inside the project, RubyGems + # auto-discovers gems.rb, puts # `-rbundler/setup` BACK into RUBYOPT, and the gemspec unshifts the real # lib/ ahead of the -I dir -- measured, this exact scrub with cwd at the # project root loads 24 files from the intact tree. Running from the @@ -208,7 +208,7 @@ def probe(tree, script) $LOAD_PATH.unshift(#{tree.inspect}) #{SupportLoadProbeTest::CUCUMBER_RUNTIME_STUB} RUBY - env = {"DELETED_TREE" => tree, "RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil, "RUBYLIB" => nil} + env = PROBE_ENV.merge("DELETED_TREE" => tree) out, status = Open3.capture2e(env, RbConfig.ruby, "-e", preamble + script, chdir: tree) "#{script.lines.first.strip} -> #{out}" unless status.success?