diff --git a/bin/lib/matrix.rb b/bin/lib/matrix.rb new file mode 100644 index 000000000..be239ba64 --- /dev/null +++ b/bin/lib/matrix.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +# Shared machinery for the per-matrix tooling. A "cell" is one row of a gem's +# test-matrix.json (the single source of truth CI reads): a Ruby plus the +# dependency versions that gem's Gemfile keys off. Both bin/relock (which +# materializes the committed lockfiles) and bin/test (which runs that cell's +# specs locally) expand cells the same way so neither can drift from CI. +# +# Each cell runs under a matching Ruby provided by mise (https://mise.jdx.dev); +# the required Rubies are declared in .mise.toml and installed with `mise install`. + +require "json" + +module Matrix + ROOT = File.expand_path("../..", __dir__) + + # The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem + # name is also the filename segment (the part before the first "-"); the + # matrix key is "_version" (redis is the exception: redis_rb_version), so + # we recover the gem from a matrix key with key.split("_").first. + # + # Values pass through verbatim: the matrix already normalizes them the way + # GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles + # wrap them in Gem::Version.new(...), so "2" and "2.0" resolve identically. + GEM_ENV_MAPPING = { + "rack" => "RACK_VERSION", + "redis" => "REDIS_RB_VERSION", + "rails" => "RAILS_VERSION", + "sidekiq" => "SIDEKIQ_VERSION" + }.freeze + + # rubyopt is a test-time concern (it doesn't affect dependency resolution, so + # relock ignores it) but it changes how specs run, so bin/test honors it. + Cell = Struct.new(:gem, :base, :ruby, :env, :rubyopt, keyword_init: true) do + def wrapper + "#{gem}/gemfiles/#{base}.gemfile" + end + + def lock + "#{wrapper}.lock" + end + + def label + "#{gem} / #{base}" + end + end + + module_function + + # Expand one test-matrix.json entry into a cell. The entry's keys (in file + # order) become the filename segments and the env the Gemfile reads: + # {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"} + # -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4} + def cell_from_entry(gem, entry) + ruby = entry.fetch("ruby_version") + segments = ["ruby-#{ruby}"] + env = {} + + entry.each do |key, value| + next if key == "ruby_version" || key == "options" + + name = key.split("_").first + var = GEM_ENV_MAPPING[name] + abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var + segments << "#{name}-#{value}" + env[var] = value + end + + Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env, rubyopt: entry.dig("options", "rubyopt")) + end + + # Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back + # into a cell (used by relock's --cell, which addresses a cell by path). + # rubyopt isn't recoverable from the path; callers that need it expand from + # test-matrix.json via cell_from_entry instead. + def parse_cell(gem, base) + segments = base.split("_") + ruby = segments.shift.sub(/\Aruby-/, "") + + env = {} + segments.each do |seg| + name, value = seg.split("-", 2) + var = GEM_ENV_MAPPING[name] + abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var + env[var] = value + end + + Cell.new(gem: gem, base: base, ruby: ruby, env: env) + end + + def matrix_path(gem) + File.join(ROOT, gem, "test-matrix.json") + end + + def discover_cells(gems) + gems.flat_map do |gem| + path = matrix_path(gem) + abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path) + JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper) + end + end + + def all_gems + Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort + end + + # Absolute path to the mise binary. It's usually a shell function (so plain + # `mise` won't resolve via execvp); ask a login shell where the real binary is. + def mise_bin + @mise_bin ||= begin + found = `sh -lc 'command -v mise' 2>/dev/null`.strip + found = found.lines.last.to_s.strip if found.include?("\n") + candidates = [ENV["MISE_BIN"], found, "/opt/homebrew/bin/mise", File.expand_path("~/.local/bin/mise"), "/usr/local/bin/mise"] + candidates.compact.find { |c| File.executable?(c) } || + abort("mise not found. Install it: https://mise.jdx.dev") + end + end + + # Whether mise has this Ruby spec installed (declared in .mise.toml). + def installed?(ruby) + system(mise_bin, "where", "ruby@#{ruby}", out: File::NULL, err: File::NULL) + end + + # Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are + # declared in .mise.toml and provisioned once via `mise install`. + def ensure_installed(cells) + missing = cells.map(&:ruby).uniq.reject { |spec| installed?(spec) } + return if missing.empty? + + warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}." + abort "Run `mise install` first." + end + + def cell_env(cell) + { "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env) + end +end diff --git a/bin/relock b/bin/relock index 4d065fefc..3e4ef9fb9 100755 --- a/bin/relock +++ b/bin/relock @@ -30,106 +30,12 @@ require "optparse" require "shellwords" -require "json" +require_relative "lib/matrix" -ROOT = File.expand_path("..", __dir__) - -# The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem name -# is also the filename segment (the part before the first "-"); the matrix key -# is "_version" (redis is the exception: redis_rb_version), so we recover -# the gem from a matrix key with key.split("_").first. -# -# Values pass through verbatim: the matrix already normalizes them the way -# GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles wrap -# them in Gem::Version.new(...), so "2" and "2.0" resolve identically — matching CI. -GEM_ENV_MAPPING = { - "rack" => "RACK_VERSION", - "redis" => "REDIS_RB_VERSION", - "rails" => "RAILS_VERSION", - "sidekiq" => "SIDEKIQ_VERSION" -}.freeze - -Cell = Struct.new(:gem, :base, :ruby, :env, keyword_init: true) do - def wrapper - "#{gem}/gemfiles/#{base}.gemfile" - end - - def lock - "#{wrapper}.lock" - end - - def label - "#{gem} / #{base}" - end -end - -# Expand one test-matrix.json entry into a cell. The entry's keys (in file -# order) become the filename segments and the env the Gemfile reads: -# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"} -# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4} -# "options" (e.g. rubyopt) is a test-time concern and doesn't affect resolution. -def cell_from_entry(gem, entry) - ruby = entry.fetch("ruby_version") - segments = ["ruby-#{ruby}"] - env = {} - - entry.each do |key, value| - next if key == "ruby_version" || key == "options" - - name = key.split("_").first - var = GEM_ENV_MAPPING[name] - abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var - segments << "#{name}-#{value}" - env[var] = value - end - - Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env) -end - -# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back into -# a cell (used by --cell, which addresses a single cell by path). -def parse_cell(gem, base) - segments = base.split("_") - ruby = segments.shift.sub(/\Aruby-/, "") - - env = {} - segments.each do |seg| - name, value = seg.split("-", 2) - var = GEM_ENV_MAPPING[name] - abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var - env[var] = value - end - - Cell.new(gem: gem, base: base, ruby: ruby, env: env) -end - -def matrix_path(gem) - File.join(ROOT, gem, "test-matrix.json") -end - -def discover_cells(gems) - gems.flat_map do |gem| - path = matrix_path(gem) - abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path) - JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper) - end -end - -def all_gems - Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort -end - -# Absolute path to the mise binary. It's usually a shell function (so plain -# `mise` won't resolve via execvp); ask a login shell where the real binary is. -def mise_bin - @mise_bin ||= begin - found = `sh -lc 'command -v mise' 2>/dev/null`.strip - found = found.lines.last.to_s.strip if found.include?("\n") - candidates = [ENV["MISE_BIN"], found, "/opt/homebrew/bin/mise", File.expand_path("~/.local/bin/mise"), "/usr/local/bin/mise"] - candidates.compact.find { |c| File.executable?(c) } || - abort("mise not found. Install it: https://mise.jdx.dev") - end -end +# Pull the shared cell/mise machinery (GEM_ENV_MAPPING, Cell, cell_from_entry, +# parse_cell, discover_cells, all_gems, mise_bin, ensure_installed, cell_env, +# ROOT) into this script's scope so the call sites below read unqualified. +include Matrix # Shell run under the cell's Ruby. Writes the wrapper, re-resolves, and adds # checksums where the bundler version supports them. @@ -141,22 +47,6 @@ RESOLVE = <<~SH bundle lock --update --add-checksums || bundle lock --update SH -# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are -# declared in .mise.toml and provisioned once via `mise install`. -def ensure_installed(cells) - missing = cells.map(&:ruby).uniq.reject do |spec| - system(mise_bin, "where", "ruby@#{spec}", out: File::NULL, err: File::NULL) - end - return if missing.empty? - - warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}." - abort "Run `mise install` first." -end - -def cell_env(cell) - { "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env) -end - def run_mise(cell) # bash -c (not -lc): inherit the PATH/env mise just set; a login shell would # re-source the profile and reset Ruby back to the host default. diff --git a/bin/test b/bin/test new file mode 100755 index 000000000..07c9f2c6e --- /dev/null +++ b/bin/test @@ -0,0 +1,134 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Run a gem's specs locally under one test-matrix cell — the local mirror of a +# single CI job. CI expands test-matrix.json into a job per cell and runs each +# under a matching Ruby with the cell's pinned deps; this picks one of those +# cells by its wrapper path and reproduces it on your machine via mise +# (https://mise.jdx.dev). +# +# A cell is keyed by its committed wrapper gemfile + sibling .gemfile.lock (run +# bin/relock to (re)generate those). We exec under the cell's mise Ruby with the +# cell's env (BUNDLE_GEMFILE, RACK_VERSION, REDIS_RB_VERSION, RUBYOPT) and run +# `bundle exec rspec` (or rake) — the same BUNDLE_FROZEN/BUNDLE_WITHOUT setup CI +# uses, so a green run here means the same as a green job there. +# +# bin/test -l # list every cell to choose from +# bin/test --gem sentry-rails # auto-pick the newest installed Ruby cell +# bin/test --gem spec/sentry/client_spec.rb # forward args to rspec +# bin/test --cell sentry-ruby/gemfiles/ruby-3.3_rack-3_redis-4.gemfile +# bin/test --cell spec/sentry/client_spec.rb # forward args to rspec +# bin/test --cell -- --tag foo # everything after -- goes to rspec +# bin/test --cell --rake # run `bundle exec rake` like CI +# +# The Ruby must already be installed (`mise install`). Specs that need Redis +# expect it on localhost:6379 (CI runs one as a service) — start your own. + +require "optparse" +require_relative "lib/matrix" +include Matrix + +opts = { + cell: nil, + gems: [], + rake: false, + list: false +} + +parser = OptionParser.new do |o| + o.banner = "Usage: bin/test (--cell PATH | --gem NAME) [options] [rspec args]" + o.on("--cell PATH", "Cell to run, by its wrapper/lock path (see -l).") { |v| opts[:cell] = v } + o.on("--gem NAME", "Gem to run (auto-picks newest installed Ruby cell); scopes -l. Repeatable for -l.") { |v| opts[:gems] << v } + o.on("--rake", "Run `bundle exec rake` (full CI task) instead of rspec.") { opts[:rake] = true } + o.on("-l", "--list", "List every cell's wrapper path, one per line; do nothing.") { opts[:list] = true } + o.on("-h", "--help") { puts o; exit 0 } +end +parser.parse!(ARGV) + +# Whatever is left after option parsing is forwarded verbatim to rspec. +rspec_args = ARGV.dup + +TTY = $stdout.tty? +def color(text, code) + TTY ? "\e[#{code}m#{text}\e[0m" : text +end + +# Bare wrapper paths, one per line — copy one into --cell. --gem scopes which +# gems we enumerate. +if opts[:list] + gems = opts[:gems].empty? ? all_gems : opts[:gems] + discover_cells(gems).each { |c| puts c.wrapper } + exit 0 +end + +if opts[:cell] + # Derive gem + base by position (/gemfiles/.gemfile), like relock's + # --cell, so absolute/relative/lock-suffixed paths all work. A gem-relative + # path (gemfiles/.gemfile, e.g. run from inside a gem dir) has no gem + # segment, so fall back to --gem for the gem. Then match the cell from + # test-matrix.json (not by parsing the path) so it carries its rubyopt — the + # frozen-string-literal cell, say, only behaves right with it set. + parts = opts[:cell].sub(/\.lock\z/, "").split("/") + gi = parts.rindex("gemfiles") + abort "--cell must point at a [/]gemfiles/.gemfile path" unless gi && parts[gi + 1] + gem = gi.positive? ? parts[gi - 1] : opts[:gems].first + abort "Can't tell which gem; use a /gemfiles/... path, --gem, or run from a gem dir." unless gem + base = File.basename(parts[gi + 1], ".gemfile") + + cell = discover_cells([gem]).find { |c| c.base == base } + abort "No cell '#{base}' in #{gem}/test-matrix.json. `bin/test -l` to list." unless cell +elsif !opts[:gems].empty? + # --gem without --cell: auto-pick the cell on the newest installed non-jruby + # Ruby (first in matrix order for that Ruby) so `--gem foo` just runs. + gem = opts[:gems].first + cells = discover_cells([gem]) + # Newest-first among MRI Rubies (jruby versions don't parse as Gem::Version); + # prefer one that's installed, else the newest so ensure_installed can explain. + mri = cells.map(&:ruby).uniq.grep_v(/jruby/).sort_by(&Gem::Version.method(:new)).reverse + ruby = mri.find { |r| installed?(r) } || mri.first + cell = cells.find { |c| c.ruby == ruby } + abort "No cell for gem '#{gem}'. `bin/test --gem #{gem} -l` to list." unless cell +else + abort "Pass --cell PATH or --gem NAME (see `bin/test -l`)." +end + +ensure_installed([cell]) + +# Mirror CI's per-job env: pin to the committed lock (BUNDLE_FROZEN), skip the +# rubocop group, and pass the matrix axes + rubyopt the Gemfile/specs read. +env = cell_env(cell).merge( + "BUNDLE_FROZEN" => "true", + "BUNDLE_WITHOUT" => "rubocop" +) +env["RUBYOPT"] = cell.rubyopt if cell.rubyopt +env["JRUBY_OPTS"] = "--debug" if cell.ruby.include?("jruby") # for more accurate coverage, as in CI + +# We chdir into the gem dir, so spec paths are gem-relative. Strip a leading +# "/" (or ".//") so a repo-root-relative path the user copied from +# `git` or tab-completion resolves too — both forms name the same file. +rspec_args = rspec_args.map { |a| a.sub(%r{\A(?:\./)?#{Regexp.escape(gem)}/}, "") } + +test_cmd = opts[:rake] ? ["rake"] : ["rspec", *rspec_args] + +# Ensure the cell's gems are present before running. The wrapper is committed, +# but write it if absent so a freshly added matrix row still works. Args reach +# the test command as bash positionals ($@) — never interpolated into the +# script — so spec paths with odd characters can't break the shell. +RUN = <<~SH + set -euo pipefail + mkdir -p "$(dirname "$BUNDLE_GEMFILE")" + [ -f "$BUNDLE_GEMFILE" ] || echo 'eval_gemfile "../Gemfile"' > "$BUNDLE_GEMFILE" + bundle check >/dev/null 2>&1 || bundle install + exec bundle exec "$@" +SH + +puts "#{color('→', '36')} #{color(cell.label, '1')} #{color("(ruby #{cell.ruby})", '90')}" +puts " #{color(test_cmd.join(' '), '90')}" + +# bash -c (not -lc): inherit the env mise just set; a login shell would reset +# Ruby to the host default. $0 is a label; "$@" is the test command. +argv = [mise_bin, "exec", "ruby@#{cell.ruby}", "--", "bash", "-c", RUN, "bin/test", *test_cmd] + +# chdir into the gem dir to match CI's working-directory. +ok = system(env, *argv, chdir: File.join(ROOT, gem)) +exit(ok ? 0 : 1) diff --git a/sentry-delayed_job/bin/test b/sentry-delayed_job/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-delayed_job/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-opentelemetry/bin/test b/sentry-opentelemetry/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-opentelemetry/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-rails/bin/test b/sentry-rails/bin/test index 1eb52afb3..1f0346dbd 100755 --- a/sentry-rails/bin/test +++ b/sentry-rails/bin/test @@ -1,389 +1,15 @@ -#!/usr/bin/env ruby +#!/usr/bin/env bash -# frozen_string_literal: true - -# Standalone CLI script to test sentry-rails against multiple Rails versions -# -# FEATURES: -# - Dedicated lock files for each Ruby/Rails version combination -# - Prevents dependency conflicts between different Rails versions -# - Automatic lock file management and restoration -# - Clean up functionality for old lock files +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. # -# LOCK FILE STRATEGY: -# Each Ruby/Rails combination gets its own lock file: -# - Ruby 3.4.5 + Rails 6.1 → Gemfile-ruby-3.4.5-rails-6.1.lock -# - Ruby 3.4.5 + Rails 7.0 → Gemfile-ruby-3.4.5-rails-7.0.lock +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI # -# Usage: -# ./bin/test --version 5.0 -# ./bin/test --all -# ./bin/test --help - -require 'optparse' -require 'fileutils' - -class RailsVersionTester - SUPPORTED_VERSIONS = %w[5.2 6.0 6.1 7.0 7.1 7.2 8.0 8.1].freeze - - def initialize - @options = {} - @failed_versions = [] - @ruby_version = RUBY_VERSION - @spec_paths = [] - end - - def run(args) - parse_options(args) - - case - when @options[:help] - show_help - when @options[:list] - list_versions - when @options[:clean] - clean_lock_files - when @options[:all] - test_all_versions - when @options[:version] - test_single_version(@options[:version]) - else - puts "Error: No action specified. Use --help for usage information." - exit(1) - end - end - - private - - def parse_options(args) - OptionParser.new do |opts| - opts.banner = "Usage: #{$0} [options] [spec_paths...]" - - opts.on("-v", "--version VERSION", "Test specific Rails version") do |version| - unless SUPPORTED_VERSIONS.include?(version) - puts "Error: Unsupported Rails version '#{version}'" - puts "Supported versions: #{SUPPORTED_VERSIONS.join(', ')}" - exit(1) - end - @options[:version] = version - end - - opts.on("-a", "--all", "Test all supported Rails versions") do - @options[:all] = true - end - - opts.on("-l", "--list", "List supported Rails versions and lock file status") do - @options[:list] = true - end - - opts.on("-c", "--clean", "Clean up old lock files for current Ruby version") do - @options[:clean] = true - end - - opts.on("-h", "--help", "Show this help message") do - @options[:help] = true - end - end.parse!(args) - - # Remaining arguments are spec paths - @spec_paths = args - end - - def show_help - puts <<~HELP - Rails Version Tester for sentry-rails - - This script tests sentry-rails against different Rails versions by: - 1. Setting the RAILS_VERSION environment variable - 2. Managing bundle dependencies with dedicated lock files per Ruby/Rails combination - 3. Running the test suite in isolated processes - 4. Providing proper exit codes for CI/CD integration - - Each Ruby/Rails version combination gets its own Gemfile.lock to prevent conflicts: - - Ruby #{@ruby_version} + Rails 6.1 → Gemfile-ruby-#{@ruby_version}-rails-6.1.lock - - Ruby #{@ruby_version} + Rails 7.0 → Gemfile-ruby-#{@ruby_version}-rails-7.0.lock - - Usage: - #{$0} --version 6.1 # Test specific Rails version (all specs) - #{$0} --version 7.0 spec/sentry/rails/log_subscribers # Test specific Rails version with specific specs - #{$0} --all # Test all supported versions - #{$0} --list # List supported versions and lock file status - #{$0} --clean # Clean up old lock files for current Ruby version - #{$0} --help # Show this help - - Supported Rails versions: #{SUPPORTED_VERSIONS.join(', ')} - - Examples: - #{$0} -v 7.1 # Test Rails 7.1 (all specs) - #{$0} -v 7.0 spec/sentry/rails/log_subscribers # Test Rails 7.0 log subscriber specs only - #{$0} -v 7.0 spec/sentry/rails/tracing # Test Rails 7.0 tracing specs only - #{$0} -a # Test all versions - #{$0} -c # Clean up old lock files - HELP - end - - def list_versions - puts "Supported Rails versions:" - SUPPORTED_VERSIONS.each do |version| - lock_file = generate_lock_file_name(version) - status = File.exist?(lock_file) ? "(has lock file)" : "(no lock file)" - puts " - #{version} #{status}" - end - puts - puts "Current Ruby version: #{@ruby_version}" - puts "Lock files are stored as: Gemfile-ruby-X.X.X-rails-Y.Y.lock" - end - - def test_all_versions - puts "Testing sentry-rails against all supported Rails versions: #{SUPPORTED_VERSIONS.join(', ')}" - puts - - SUPPORTED_VERSIONS.each do |version| - puts "=" * 60 - puts "Testing Rails #{version}" - puts "=" * 60 - - exit_code = test_rails_version(version) - - if exit_code == 0 - puts "✓ Rails #{version} - PASSED" - else - puts "✗ Rails #{version} - FAILED (exit code: #{exit_code})" - @failed_versions << version - end - puts - end - - print_summary - end - - def test_single_version(version) - puts "Testing sentry-rails against Rails #{version}..." - exit_code = test_rails_version(version) - exit(exit_code) unless exit_code == 0 - end - - def test_rails_version(version) - puts "Setting up environment for Rails #{version}..." - - # Generate dedicated lock file name for this Ruby/Rails combination - dedicated_lock_file = generate_lock_file_name(version) - current_lock_file = "Gemfile.lock" - - # Set up environment variables - env = { - "RAILS_VERSION" => version, - "BUNDLE_GEMFILE" => File.expand_path("Gemfile", Dir.pwd) - } - - puts "Using dedicated lock file: #{dedicated_lock_file}" - - # Manage lock file switching - setup_lock_file(dedicated_lock_file, current_lock_file) - - begin - # Check if bundle update is needed - if bundle_update_needed?(env, dedicated_lock_file) - puts "Dependencies need to be updated for Rails #{version}..." - unless update_bundle(env, dedicated_lock_file) - puts "✗ Failed to update bundle for Rails #{version}" - return 1 - end - end - - # Run the tests in a separate process - puts "Running test suite..." - run_tests(env, @spec_paths) - ensure - # Save the current lock file back to the dedicated location - save_lock_file(dedicated_lock_file, current_lock_file) - end - end - - def generate_lock_file_name(rails_version) - # Create a unique lock file name for this Ruby/Rails combination - ruby_version_clean = @ruby_version.gsub(/[^\d\.]/, '') - rails_version_clean = rails_version.gsub(/[^\d\.]/, '') - "Gemfile-ruby-#{ruby_version_clean}-rails-#{rails_version_clean}.lock" - end - - def setup_lock_file(dedicated_lock_file, current_lock_file) - # If we have a dedicated lock file, copy it to the current location - if File.exist?(dedicated_lock_file) - puts "Restoring lock file from #{dedicated_lock_file}" - FileUtils.cp(dedicated_lock_file, current_lock_file) - elsif File.exist?(current_lock_file) - # If no dedicated lock file exists but current one does, remove it - # so we get a fresh resolution - puts "Removing existing lock file for fresh dependency resolution" - File.delete(current_lock_file) - end - end - - def save_lock_file(dedicated_lock_file, current_lock_file) - # Save the current lock file to the dedicated location - if File.exist?(current_lock_file) - puts "Saving lock file to #{dedicated_lock_file}" - FileUtils.cp(current_lock_file, dedicated_lock_file) - end - end - - def bundle_update_needed?(env, dedicated_lock_file) - # Check if current Gemfile.lock exists - current_lock_file = "Gemfile.lock" - gemfile_path = env["BUNDLE_GEMFILE"] || "Gemfile" - - return true unless File.exist?(current_lock_file) - - # Check if Gemfile is newer than the current lock file - return true if File.mtime(gemfile_path) > File.mtime(current_lock_file) - - # For Rails version changes, check if lockfile has incompatible Rails version - if env["RAILS_VERSION"] && lockfile_has_incompatible_rails_version?(current_lock_file, env["RAILS_VERSION"]) - return true - end - - # Check if bundle check passes - system(env, "bundle check > /dev/null 2>&1") == false - end - - def lockfile_has_incompatible_rails_version?(lockfile_path, target_rails_version) - return false unless File.exist?(lockfile_path) - - lockfile_content = File.read(lockfile_path) - - # Extract Rails version from lockfile - if lockfile_content =~ /^\s*rails \(([^)]+)\)/ - locked_rails_version = $1 - target_major_minor = target_rails_version.split('.')[0..1].join('.') - locked_major_minor = locked_rails_version.split('.')[0..1].join('.') - - # If major.minor versions don't match, we need to update - return target_major_minor != locked_major_minor - end - - # If we can't determine the Rails version, assume update is needed - true - end - - def update_bundle(env, dedicated_lock_file) - puts "Updating bundle for Rails #{env['RAILS_VERSION']}..." - - current_lock_file = "Gemfile.lock" - - # Try bundle update first - if system(env, "bundle update --quiet") - puts "Bundle updated successfully" - return true - end - - puts "Bundle update failed, trying clean install..." - - # Remove the current lockfile and try fresh install - File.delete(current_lock_file) if File.exist?(current_lock_file) - - if system(env, "bundle install --quiet") - puts "Bundle installed successfully" - return true - end - - puts "Bundle install failed" - false - end - - def run_tests(env, spec_paths = []) - # Determine the command to run - if spec_paths.empty? - # Run all tests via rake - command = "bundle exec rake" - else - # Run specific specs via rspec - command = "bundle exec rspec #{spec_paths.join(' ')}" - end - - puts "Executing: #{command}" - - # Run the tests in a separate process with proper signal handling - pid = spawn(env, command, - out: $stdout, - err: $stderr, - pgroup: true) - - begin - _, status = Process.wait2(pid) - status.exitstatus - rescue Interrupt - puts "\nInterrupted! Terminating test process..." - terminate_process_group(pid) - 130 # Standard exit code for SIGINT - end - end - - def terminate_process_group(pid) - begin - Process.kill("TERM", -pid) # Kill the process group - sleep(2) - Process.kill("KILL", -pid) if process_running?(pid) - rescue Errno::ESRCH - # Process already terminated - end - end - - def process_running?(pid) - Process.getpgid(pid) - true - rescue Errno::ESRCH - false - end - - def clean_lock_files - puts "Cleaning up lock files for Ruby #{@ruby_version}..." - - # Find all lock files matching our pattern - pattern = "Gemfile-ruby-#{@ruby_version.gsub(/[^\d\.]/, '')}-rails-*.lock" - lock_files = Dir.glob(pattern) - - if lock_files.empty? - puts "No lock files found matching pattern: #{pattern}" - return - end - - puts "Found #{lock_files.length} lock file(s):" - lock_files.each { |file| puts " - #{file}" } - - print "Delete these files? [y/N]: " - response = $stdin.gets.chomp.downcase - - if response == 'y' || response == 'yes' - lock_files.each do |file| - File.delete(file) - puts "Deleted: #{file}" - end - puts "Cleanup complete!" - else - puts "Cleanup cancelled." - end - end - - def print_summary - puts "=" * 60 - puts "SUMMARY" - puts "=" * 60 - - if @failed_versions.empty? - puts "✓ All Rails versions passed!" - exit(0) - else - puts "✗ Failed versions: #{@failed_versions.join(', ')}" - puts - puts "Some Rails versions failed. See output above for details." - exit(1) - end - end -end - -# Run the script if called directly -if __FILE__ == $0 - tester = RailsVersionTester.new - tester.run(ARGV) -end +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-rails/bin/test_old b/sentry-rails/bin/test_old new file mode 100755 index 000000000..1eb52afb3 --- /dev/null +++ b/sentry-rails/bin/test_old @@ -0,0 +1,389 @@ +#!/usr/bin/env ruby + +# frozen_string_literal: true + +# Standalone CLI script to test sentry-rails against multiple Rails versions +# +# FEATURES: +# - Dedicated lock files for each Ruby/Rails version combination +# - Prevents dependency conflicts between different Rails versions +# - Automatic lock file management and restoration +# - Clean up functionality for old lock files +# +# LOCK FILE STRATEGY: +# Each Ruby/Rails combination gets its own lock file: +# - Ruby 3.4.5 + Rails 6.1 → Gemfile-ruby-3.4.5-rails-6.1.lock +# - Ruby 3.4.5 + Rails 7.0 → Gemfile-ruby-3.4.5-rails-7.0.lock +# +# Usage: +# ./bin/test --version 5.0 +# ./bin/test --all +# ./bin/test --help + +require 'optparse' +require 'fileutils' + +class RailsVersionTester + SUPPORTED_VERSIONS = %w[5.2 6.0 6.1 7.0 7.1 7.2 8.0 8.1].freeze + + def initialize + @options = {} + @failed_versions = [] + @ruby_version = RUBY_VERSION + @spec_paths = [] + end + + def run(args) + parse_options(args) + + case + when @options[:help] + show_help + when @options[:list] + list_versions + when @options[:clean] + clean_lock_files + when @options[:all] + test_all_versions + when @options[:version] + test_single_version(@options[:version]) + else + puts "Error: No action specified. Use --help for usage information." + exit(1) + end + end + + private + + def parse_options(args) + OptionParser.new do |opts| + opts.banner = "Usage: #{$0} [options] [spec_paths...]" + + opts.on("-v", "--version VERSION", "Test specific Rails version") do |version| + unless SUPPORTED_VERSIONS.include?(version) + puts "Error: Unsupported Rails version '#{version}'" + puts "Supported versions: #{SUPPORTED_VERSIONS.join(', ')}" + exit(1) + end + @options[:version] = version + end + + opts.on("-a", "--all", "Test all supported Rails versions") do + @options[:all] = true + end + + opts.on("-l", "--list", "List supported Rails versions and lock file status") do + @options[:list] = true + end + + opts.on("-c", "--clean", "Clean up old lock files for current Ruby version") do + @options[:clean] = true + end + + opts.on("-h", "--help", "Show this help message") do + @options[:help] = true + end + end.parse!(args) + + # Remaining arguments are spec paths + @spec_paths = args + end + + def show_help + puts <<~HELP + Rails Version Tester for sentry-rails + + This script tests sentry-rails against different Rails versions by: + 1. Setting the RAILS_VERSION environment variable + 2. Managing bundle dependencies with dedicated lock files per Ruby/Rails combination + 3. Running the test suite in isolated processes + 4. Providing proper exit codes for CI/CD integration + + Each Ruby/Rails version combination gets its own Gemfile.lock to prevent conflicts: + - Ruby #{@ruby_version} + Rails 6.1 → Gemfile-ruby-#{@ruby_version}-rails-6.1.lock + - Ruby #{@ruby_version} + Rails 7.0 → Gemfile-ruby-#{@ruby_version}-rails-7.0.lock + + Usage: + #{$0} --version 6.1 # Test specific Rails version (all specs) + #{$0} --version 7.0 spec/sentry/rails/log_subscribers # Test specific Rails version with specific specs + #{$0} --all # Test all supported versions + #{$0} --list # List supported versions and lock file status + #{$0} --clean # Clean up old lock files for current Ruby version + #{$0} --help # Show this help + + Supported Rails versions: #{SUPPORTED_VERSIONS.join(', ')} + + Examples: + #{$0} -v 7.1 # Test Rails 7.1 (all specs) + #{$0} -v 7.0 spec/sentry/rails/log_subscribers # Test Rails 7.0 log subscriber specs only + #{$0} -v 7.0 spec/sentry/rails/tracing # Test Rails 7.0 tracing specs only + #{$0} -a # Test all versions + #{$0} -c # Clean up old lock files + HELP + end + + def list_versions + puts "Supported Rails versions:" + SUPPORTED_VERSIONS.each do |version| + lock_file = generate_lock_file_name(version) + status = File.exist?(lock_file) ? "(has lock file)" : "(no lock file)" + puts " - #{version} #{status}" + end + puts + puts "Current Ruby version: #{@ruby_version}" + puts "Lock files are stored as: Gemfile-ruby-X.X.X-rails-Y.Y.lock" + end + + def test_all_versions + puts "Testing sentry-rails against all supported Rails versions: #{SUPPORTED_VERSIONS.join(', ')}" + puts + + SUPPORTED_VERSIONS.each do |version| + puts "=" * 60 + puts "Testing Rails #{version}" + puts "=" * 60 + + exit_code = test_rails_version(version) + + if exit_code == 0 + puts "✓ Rails #{version} - PASSED" + else + puts "✗ Rails #{version} - FAILED (exit code: #{exit_code})" + @failed_versions << version + end + puts + end + + print_summary + end + + def test_single_version(version) + puts "Testing sentry-rails against Rails #{version}..." + exit_code = test_rails_version(version) + exit(exit_code) unless exit_code == 0 + end + + def test_rails_version(version) + puts "Setting up environment for Rails #{version}..." + + # Generate dedicated lock file name for this Ruby/Rails combination + dedicated_lock_file = generate_lock_file_name(version) + current_lock_file = "Gemfile.lock" + + # Set up environment variables + env = { + "RAILS_VERSION" => version, + "BUNDLE_GEMFILE" => File.expand_path("Gemfile", Dir.pwd) + } + + puts "Using dedicated lock file: #{dedicated_lock_file}" + + # Manage lock file switching + setup_lock_file(dedicated_lock_file, current_lock_file) + + begin + # Check if bundle update is needed + if bundle_update_needed?(env, dedicated_lock_file) + puts "Dependencies need to be updated for Rails #{version}..." + unless update_bundle(env, dedicated_lock_file) + puts "✗ Failed to update bundle for Rails #{version}" + return 1 + end + end + + # Run the tests in a separate process + puts "Running test suite..." + run_tests(env, @spec_paths) + ensure + # Save the current lock file back to the dedicated location + save_lock_file(dedicated_lock_file, current_lock_file) + end + end + + def generate_lock_file_name(rails_version) + # Create a unique lock file name for this Ruby/Rails combination + ruby_version_clean = @ruby_version.gsub(/[^\d\.]/, '') + rails_version_clean = rails_version.gsub(/[^\d\.]/, '') + "Gemfile-ruby-#{ruby_version_clean}-rails-#{rails_version_clean}.lock" + end + + def setup_lock_file(dedicated_lock_file, current_lock_file) + # If we have a dedicated lock file, copy it to the current location + if File.exist?(dedicated_lock_file) + puts "Restoring lock file from #{dedicated_lock_file}" + FileUtils.cp(dedicated_lock_file, current_lock_file) + elsif File.exist?(current_lock_file) + # If no dedicated lock file exists but current one does, remove it + # so we get a fresh resolution + puts "Removing existing lock file for fresh dependency resolution" + File.delete(current_lock_file) + end + end + + def save_lock_file(dedicated_lock_file, current_lock_file) + # Save the current lock file to the dedicated location + if File.exist?(current_lock_file) + puts "Saving lock file to #{dedicated_lock_file}" + FileUtils.cp(current_lock_file, dedicated_lock_file) + end + end + + def bundle_update_needed?(env, dedicated_lock_file) + # Check if current Gemfile.lock exists + current_lock_file = "Gemfile.lock" + gemfile_path = env["BUNDLE_GEMFILE"] || "Gemfile" + + return true unless File.exist?(current_lock_file) + + # Check if Gemfile is newer than the current lock file + return true if File.mtime(gemfile_path) > File.mtime(current_lock_file) + + # For Rails version changes, check if lockfile has incompatible Rails version + if env["RAILS_VERSION"] && lockfile_has_incompatible_rails_version?(current_lock_file, env["RAILS_VERSION"]) + return true + end + + # Check if bundle check passes + system(env, "bundle check > /dev/null 2>&1") == false + end + + def lockfile_has_incompatible_rails_version?(lockfile_path, target_rails_version) + return false unless File.exist?(lockfile_path) + + lockfile_content = File.read(lockfile_path) + + # Extract Rails version from lockfile + if lockfile_content =~ /^\s*rails \(([^)]+)\)/ + locked_rails_version = $1 + target_major_minor = target_rails_version.split('.')[0..1].join('.') + locked_major_minor = locked_rails_version.split('.')[0..1].join('.') + + # If major.minor versions don't match, we need to update + return target_major_minor != locked_major_minor + end + + # If we can't determine the Rails version, assume update is needed + true + end + + def update_bundle(env, dedicated_lock_file) + puts "Updating bundle for Rails #{env['RAILS_VERSION']}..." + + current_lock_file = "Gemfile.lock" + + # Try bundle update first + if system(env, "bundle update --quiet") + puts "Bundle updated successfully" + return true + end + + puts "Bundle update failed, trying clean install..." + + # Remove the current lockfile and try fresh install + File.delete(current_lock_file) if File.exist?(current_lock_file) + + if system(env, "bundle install --quiet") + puts "Bundle installed successfully" + return true + end + + puts "Bundle install failed" + false + end + + def run_tests(env, spec_paths = []) + # Determine the command to run + if spec_paths.empty? + # Run all tests via rake + command = "bundle exec rake" + else + # Run specific specs via rspec + command = "bundle exec rspec #{spec_paths.join(' ')}" + end + + puts "Executing: #{command}" + + # Run the tests in a separate process with proper signal handling + pid = spawn(env, command, + out: $stdout, + err: $stderr, + pgroup: true) + + begin + _, status = Process.wait2(pid) + status.exitstatus + rescue Interrupt + puts "\nInterrupted! Terminating test process..." + terminate_process_group(pid) + 130 # Standard exit code for SIGINT + end + end + + def terminate_process_group(pid) + begin + Process.kill("TERM", -pid) # Kill the process group + sleep(2) + Process.kill("KILL", -pid) if process_running?(pid) + rescue Errno::ESRCH + # Process already terminated + end + end + + def process_running?(pid) + Process.getpgid(pid) + true + rescue Errno::ESRCH + false + end + + def clean_lock_files + puts "Cleaning up lock files for Ruby #{@ruby_version}..." + + # Find all lock files matching our pattern + pattern = "Gemfile-ruby-#{@ruby_version.gsub(/[^\d\.]/, '')}-rails-*.lock" + lock_files = Dir.glob(pattern) + + if lock_files.empty? + puts "No lock files found matching pattern: #{pattern}" + return + end + + puts "Found #{lock_files.length} lock file(s):" + lock_files.each { |file| puts " - #{file}" } + + print "Delete these files? [y/N]: " + response = $stdin.gets.chomp.downcase + + if response == 'y' || response == 'yes' + lock_files.each do |file| + File.delete(file) + puts "Deleted: #{file}" + end + puts "Cleanup complete!" + else + puts "Cleanup cancelled." + end + end + + def print_summary + puts "=" * 60 + puts "SUMMARY" + puts "=" * 60 + + if @failed_versions.empty? + puts "✓ All Rails versions passed!" + exit(0) + else + puts "✗ Failed versions: #{@failed_versions.join(', ')}" + puts + puts "Some Rails versions failed. See output above for details." + exit(1) + end + end +end + +# Run the script if called directly +if __FILE__ == $0 + tester = RailsVersionTester.new + tester.run(ARGV) +end diff --git a/sentry-resque/bin/test b/sentry-resque/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-resque/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-ruby/bin/test b/sentry-ruby/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-ruby/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-sidekiq/bin/test b/sentry-sidekiq/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-sidekiq/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@" diff --git a/sentry-yabeda/bin/test b/sentry-yabeda/bin/test new file mode 100755 index 000000000..1f0346dbd --- /dev/null +++ b/sentry-yabeda/bin/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Thin wrapper so you can run this gem's specs from inside its own directory: +# forwards to the repo-root bin/test scoped to this gem (--gem ). All +# args pass through, so spec paths and flags work as documented there. +# +# bin/test # auto-pick the newest installed Ruby cell +# bin/test spec/.../foo_spec.rb # forward args to rspec +# bin/test --cell # pin an exact cell +# bin/test --rake # run `bundle exec rake` like CI +# +# See `../../bin/test --help` for the full interface. +set -euo pipefail +gem_dir="$(cd "$(dirname "$0")/.." && pwd)" +exec "$(dirname "$gem_dir")/bin/test" --gem "$(basename "$gem_dir")" "$@"