Skip to content

Commit b4c79c2

Browse files
sl0thentr0pyclaude
andauthored
test: Add bin/test helper to invoke specs with mise (#2983)
* `./bin/test -l`: list all cells across gems * `./bin/test --gem <gem>`: scope by gem, picks latest cell automatically if not supplied * `./bin/test --cell <cell>`: run a cell's specs under its mise ruby * `./bin/test --cell <cell> -- <spec args>`: forward args to rspec * `./bin/test --cell <cell> --rake`: run `bundle exec rake` like CI * extract shared cell/mise machinery into `bin/lib/matrix.rb` (used by relock and test) * per gem `./bin/test` wrappers that pass through to top level command * rename old rails `bin/test` -> `bin/test_old` * part of: #2961 * part of: RUBY-180 Claude-Session: https://claude.ai/code/session_01Pmtt83HAJyyofGwhRGgcoj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82f1bde commit b4c79c2

13 files changed

Lines changed: 815 additions & 532 deletions

File tree

AGENTS.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# Agent Instructions
22

3-
## Package Manager
4-
Use **Bundler**. Each gem has its own `Gemfile`; run commands from within the gem subdirectory.
3+
## Toolchain (mise)
4+
[mise](https://mise.jdx.dev) manages Rubies and runs tasks. `.mise.toml` pins a single default Ruby for local work; `.mise.ci.toml` (loaded with `MISE_ENV=ci`) pins the full per-matrix Ruby set that CI, `bin/test`, and `bin/relock` resolve against.
5+
56
```bash
6-
cd sentry-ruby && bundle install
7+
mise install # install the default toolchain (.mise.toml)
8+
mise --env ci install # install the full CI matrix of Rubies (needed for bin/test)
79
```
810

911
## Monorepo Structure
@@ -19,18 +21,30 @@ cd sentry-ruby && bundle install
1921

2022
Shared test infrastructure lives in `lib/sentry/test/`. Root `Gemfile.dev` defines shared dev dependencies.
2123

22-
## File-Scoped Commands
24+
## Testing
25+
Use `bin/test` (from the repo root) to run a gem's specs under a single CI test-matrix cell — the local mirror of one CI job. The Ruby must already be installed (`mise --env ci install`).
26+
You can also invoke `bin/test` from any of the gem directories themselves which automatically fills in the `--gem` part.
27+
28+
| Task | Command |
29+
|------|---------|
30+
| List every cell to choose from | `bin/test -l` |
31+
| Run a gem (auto-picks newest installed Ruby cell) | `bin/test --gem sentry-rails` |
32+
| Run a single spec | `bin/test --gem sentry-ruby spec/sentry/client_spec.rb` |
33+
| Run a specific cell | `bin/test --cell sentry-ruby/gemfiles/ruby-3.3_rack-3_redis-4.gemfile` |
34+
| Forward args to rspec | `bin/test --cell <cell> -- --tag foo` |
35+
| Run full CI rake task | `bin/test --gem <gem> --rake` |
36+
37+
Root-level `bundle exec rake` runs the E2E/integration spec suite (not individual gem tests).
38+
39+
## Lint
2340
Run from within the target gem directory (e.g. `cd sentry-ruby`):
2441

2542
| Task | Command |
2643
|------|---------|
27-
| Install deps | `bundle install` |
28-
| Run all tests | `bundle exec rake` |
29-
| Run single spec | `bundle exec rspec spec/sentry/client_spec.rb` |
3044
| Lint | `bundle exec rubocop path/to/file.rb` |
3145
| Lint (autofix) | `bundle exec rubocop -a path/to/file.rb` |
3246

33-
Root-level `bundle exec rubocop` lints the entire repo. Root-level `bundle exec rake` runs the E2E/integration spec suite (not individual gem tests).
47+
Root-level `bundle exec rubocop` lints the entire repo.
3448

3549
## Testing Conventions
3650
- Framework: **RSpec**

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ This file defines which specific image and Ruby version will be used to run the
4242
bundle install
4343
```
4444
- Install any additional dependencies. `sentry-sidekiq` assumes you have `redis` running.
45-
- Use `bundle exec rake` to run tests.
45+
- Use the `./bin/test` helper to run tests.
4646
- In `sentry-rails`, you can use `RAILS_VERSION=version` to specify the Rails version to test against. Default is `8.0`
4747
- In `sentry-sidekiq`, you can use `SIDEKIQ_VERSION=version` to specify what version of Sidekiq to install when you run `bundle install`. Default is `7.0`
4848
- Use example apps under the `example` or `examples` folder to test the change. (Remember to change the DSN first)

bin/lib/matrix.rb

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# frozen_string_literal: true
2+
3+
# Shared machinery for the per-matrix tooling.
4+
# A "cell" is one row of a gem's test-matrix.json (the single source of truth CI reads)
5+
# Both bin/relock (which materializes the committed lockfiles)
6+
# and bin/test (which runs that cell's specs locally) expand cells the same way so neither can drift from CI.
7+
#
8+
# Each cell runs under a matching Ruby provided by mise (https://mise.jdx.dev);
9+
# the required Rubies are declared in .mise.ci.toml and installed with
10+
# `mise --env ci install`.
11+
12+
require "json"
13+
14+
module Matrix
15+
ROOT = File.expand_path("../..", __dir__)
16+
17+
# translate gem versions to env vars picked up by Gemfile
18+
GEM_ENV_MAPPING = {
19+
"rack" => "RACK_VERSION",
20+
"redis" => "REDIS_RB_VERSION",
21+
"rails" => "RAILS_VERSION",
22+
"sidekiq" => "SIDEKIQ_VERSION"
23+
}.freeze
24+
25+
Cell = Struct.new(:gem, :base, :ruby, :env, :rubyopt, keyword_init: true) do
26+
def wrapper
27+
"#{gem}/gemfiles/#{base}.gemfile"
28+
end
29+
30+
def lock
31+
"#{wrapper}.lock"
32+
end
33+
34+
def label
35+
"#{gem} / #{base}"
36+
end
37+
end
38+
39+
module_function
40+
41+
# Expand one test-matrix.json entry into a cell. The entry's keys (in file
42+
# order) become the filename segments and the env the Gemfile reads:
43+
# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"}
44+
# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4}
45+
def cell_from_entry(gem, entry)
46+
ruby = entry.fetch("ruby_version")
47+
segments = ["ruby-#{ruby}"]
48+
env = {}
49+
50+
entry.each do |key, value|
51+
next if key == "ruby_version" || key == "options"
52+
53+
name = key.split("_").first
54+
var = GEM_ENV_MAPPING[name]
55+
abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var
56+
segments << "#{name}-#{value}"
57+
env[var] = value
58+
end
59+
60+
Cell.new(gem: gem,
61+
base: segments.join("_"),
62+
ruby: ruby,
63+
env: env,
64+
rubyopt: entry.dig("options", "rubyopt"))
65+
end
66+
67+
# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back
68+
# into a cell (used by relock's --cell, which addresses a cell by path).
69+
# rubyopt isn't recoverable from the path; callers that need it expand from
70+
# test-matrix.json via cell_from_entry instead.
71+
def parse_cell(gem, base)
72+
segments = base.split("_")
73+
ruby = segments.shift.sub(/\Aruby-/, "")
74+
75+
env = {}
76+
segments.each do |seg|
77+
name, value = seg.split("-", 2)
78+
var = GEM_ENV_MAPPING[name]
79+
abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var
80+
env[var] = value
81+
end
82+
83+
Cell.new(gem: gem, base: base, ruby: ruby, env: env)
84+
end
85+
86+
# Split a wrapper/lock path into [gem, base] by position, since a cell is
87+
# addressed as <gem>/gemfiles/<base>.gemfile. Works for absolute, relative,
88+
# and .lock-suffixed paths. A gem-relative path (gemfiles/<base>.gemfile, e.g.
89+
# run from inside a gem dir) has no <gem> segment, so gem falls back to
90+
# fallback_gem (nil if none). Returns nil when the path has no gemfiles/<base>.
91+
def cell_path_parts(path, fallback_gem: nil)
92+
parts = path.sub(/\.lock\z/, "").split("/")
93+
gi = parts.rindex("gemfiles")
94+
return nil unless gi && parts[gi + 1]
95+
96+
gem = gi.positive? ? parts[gi - 1] : fallback_gem
97+
[gem, File.basename(parts[gi + 1], ".gemfile")]
98+
end
99+
100+
def matrix_path(gem)
101+
File.join(ROOT, gem, "test-matrix.json")
102+
end
103+
104+
def discover_cells(gems)
105+
gems.flat_map do |gem|
106+
path = matrix_path(gem)
107+
abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path)
108+
JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper)
109+
end
110+
end
111+
112+
def all_gems
113+
Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort
114+
end
115+
116+
def mise_bin
117+
@mise_bin ||= begin
118+
found = `sh -lc 'command -v mise' 2>/dev/null`.strip
119+
found = found.lines.last.to_s.strip if found.include?("\n")
120+
121+
candidates = [
122+
ENV["MISE_BIN"],
123+
found,
124+
"/opt/homebrew/bin/mise",
125+
File.expand_path("~/.local/bin/mise"),
126+
"/usr/local/bin/mise"
127+
]
128+
129+
candidates.compact.find { |c| File.executable?(c) } ||
130+
abort("mise not found. Install it: https://mise.jdx.dev")
131+
end
132+
end
133+
134+
def installed?(ruby)
135+
system(mise_bin, "where", "ruby@#{ruby}", out: File::NULL, err: File::NULL)
136+
end
137+
138+
def ensure_installed(cells)
139+
missing = cells.map(&:ruby).uniq.reject { |spec| installed?(spec) }
140+
return if missing.empty?
141+
142+
warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}."
143+
abort "Run `mise --env ci install` first."
144+
end
145+
146+
def cell_env(cell)
147+
{ "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
148+
end
149+
end

bin/relock

Lines changed: 11 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -31,117 +31,13 @@
3131
# See --help for all options.
3232

3333
require "optparse"
34-
require "json"
35-
36-
ROOT = File.expand_path("..", __dir__)
37-
38-
# The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem name
39-
# is also the filename segment (the part before the first "-"); the matrix key
40-
# is "<gem>_version" (redis is the exception: redis_rb_version), so we recover
41-
# the gem from a matrix key with key.split("_").first.
42-
#
43-
# Values pass through verbatim: the matrix already normalizes them the way
44-
# GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles wrap
45-
# them in Gem::Version.new(...), so "2" and "2.0" resolve identically — matching CI.
46-
GEM_ENV_MAPPING = {
47-
"rack" => "RACK_VERSION",
48-
"redis" => "REDIS_RB_VERSION",
49-
"rails" => "RAILS_VERSION",
50-
"sidekiq" => "SIDEKIQ_VERSION"
51-
}.freeze
52-
53-
Cell = Struct.new(:gem, :base, :ruby, :env, keyword_init: true) do
54-
def wrapper
55-
"#{gem}/gemfiles/#{base}.gemfile"
56-
end
57-
58-
def lock
59-
"#{wrapper}.lock"
60-
end
61-
62-
def label
63-
"#{gem} / #{base}"
64-
end
65-
end
66-
67-
# Expand one test-matrix.json entry into a cell. The entry's keys (in file
68-
# order) become the filename segments and the env the Gemfile reads:
69-
# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"}
70-
# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4}
71-
# "options" (e.g. rubyopt) is a test-time concern and doesn't affect resolution.
72-
def cell_from_entry(gem, entry)
73-
ruby = entry.fetch("ruby_version")
74-
segments = ["ruby-#{ruby}"]
75-
env = {}
76-
77-
entry.each do |key, value|
78-
next if key == "ruby_version" || key == "options"
79-
80-
name = key.split("_").first
81-
var = GEM_ENV_MAPPING[name]
82-
abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var
83-
segments << "#{name}-#{value}"
84-
env[var] = value
85-
end
86-
87-
Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env)
88-
end
89-
90-
# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back into
91-
# a cell (used by --cell, which addresses a single cell by path).
92-
def parse_cell(gem, base)
93-
segments = base.split("_")
94-
ruby = segments.shift.sub(/\Aruby-/, "")
95-
96-
env = {}
97-
segments.each do |seg|
98-
name, value = seg.split("-", 2)
99-
var = GEM_ENV_MAPPING[name]
100-
abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var
101-
env[var] = value
102-
end
103-
104-
Cell.new(gem: gem, base: base, ruby: ruby, env: env)
105-
end
106-
107-
def matrix_path(gem)
108-
File.join(ROOT, gem, "test-matrix.json")
109-
end
110-
111-
def discover_cells(gems)
112-
gems.flat_map do |gem|
113-
path = matrix_path(gem)
114-
abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path)
115-
JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper)
116-
end
117-
end
118-
119-
def all_gems
120-
Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort
121-
end
122-
123-
# Absolute path to the mise binary. It's usually a shell function (so plain
124-
# `mise` won't resolve via execvp); ask a login shell where the real binary is.
125-
def mise_bin
126-
@mise_bin ||= begin
127-
# `command -v` can emit profile noise on earlier lines; the real path is last.
128-
found = `sh -lc 'command -v mise' 2>/dev/null`.lines.last.to_s.strip
129-
candidates = [
130-
ENV["MISE_BIN"],
131-
found,
132-
"/opt/homebrew/bin/mise",
133-
File.expand_path("~/.local/bin/mise"),
134-
"/usr/local/bin/mise"
135-
]
136-
candidates.compact.find { |c| File.executable?(c) } ||
137-
abort("mise not found. Install it: https://mise.jdx.dev")
138-
end
139-
end
34+
require_relative "lib/matrix"
35+
include Matrix
14036

14137
# Shell run under the cell's Ruby. Writes the wrapper, re-resolves, and adds
142-
# checksums where the bundler version supports them. When FORCE is set, the
143-
# existing lock is deleted first so bundler resolves from scratch — slower, but
144-
# sidesteps edge cases where an incremental update gets stuck on a stale lock.
38+
# checksums where the bundler version supports them.
39+
#
40+
# When FORCE is set, the existing lock is deleted first so bundler resolves from scratch, use for edge cases.
14541
RESOLVE = <<~SH
14642
set -euo pipefail
14743
mkdir -p "$(dirname "$BUNDLE_GEMFILE")"
@@ -162,29 +58,13 @@ RESOLVE = <<~SH
16258
fi
16359
SH
16460

165-
# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are
166-
# declared in .mise.ci.toml and provisioned once via `mise --env ci install`.
167-
def ensure_installed(cells)
168-
missing = cells.map(&:ruby).uniq.reject do |spec|
169-
system(mise_bin, "where", "ruby@#{spec}", out: File::NULL, err: File::NULL)
170-
end
171-
return if missing.empty?
172-
173-
warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}."
174-
abort "Run `mise --env ci install` first."
175-
end
176-
177-
def cell_env(cell, force: false)
178-
env = { "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
179-
env["FORCE"] = "1" if force
180-
env
181-
end
182-
18361
def run_mise(cell, force: false)
18462
# bash -c (not -lc): inherit the PATH/env mise just set; a login shell would
18563
# re-source the profile and reset Ruby back to the host default.
18664
argv = [mise_bin, "exec", "ruby@#{cell.ruby}", "--", "bash", "-c", RESOLVE]
187-
[cell_env(cell, force: force), argv]
65+
env = cell_env(cell)
66+
env = env.merge("FORCE" => "1") if force
67+
[env, argv]
18868
end
18969

19070
# ---- options -------------------------------------------------------------
@@ -209,15 +89,9 @@ parser.parse!(ARGV)
20989
# ---- select cells --------------------------------------------------------
21090

21191
if opts[:cell]
212-
# Single explicit cell. Derive gem + base by position
213-
# (<gem>/gemfiles/<base>.gemfile) so it works for absolute, relative, or
214-
# symlinked paths. Accept either the .gemfile or .gemfile.lock form.
215-
parts = opts[:cell].sub(/\.lock\z/, "").split("/")
216-
gi = parts.rindex("gemfiles")
217-
# Positive (not just non-nil): we need a <gem> segment before "gemfiles".
218-
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile path" unless gi&.positive?
219-
gem = parts[gi - 1]
220-
base = File.basename(parts[gi + 1], ".gemfile")
92+
# single explicit cell
93+
gem, base = cell_path_parts(opts[:cell])
94+
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile path" unless gem && base
22195
cells = [parse_cell(gem, base)]
22296
else
22397
gems = opts[:gems].empty? ? all_gems : opts[:gems]

0 commit comments

Comments
 (0)