Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Regression tests for backtrace source mapping: unit coverage for `RSpock::BacktraceFilter` / `RSpock::Minitest::BacktraceFilter`, and a subprocess integration test pinning that failing tests report source line numbers — including one documenting that the Minitest plugin filter is still required for line mapping (ast-transform alone only corrects file paths).

## [2.5.0] - 2026-02-28

### Added
Expand Down
131 changes: 131 additions & 0 deletions test/rspock/backtrace_filter_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'ast_transform/transformation'
require 'ast_transform/transformer'
require 'rspock/declarative'
require 'rspock/ast/transformation'
require 'rspock/backtrace_filter'

module RSpock
# ::Minitest is explicit throughout: requiring rspock/minitest/backtrace_filter
# defines RSpock::Minitest, which shadows the top-level constant inside this module.
class BacktraceFilterTest < ::Minitest::Test
extend RSpock::Declarative

# Multi-line expressions collapse during unparse and the rescue wrapper
# shifts the class body, so transformed line numbers differ from source
# line numbers — making the mapping observable.
FIXTURE_SOURCE = <<~RUBY
transform!(RSpock::AST::Transformation)
class BacktraceFilterFixture
def kaboom
value = 1 +
2 +
3
raise ArgumentError,
"kaboom-\#{value}"
end
end
RUBY

test "filter_string maps a transformed line number back to the source line" do
with_registered_source_map do |source_path, _transformed_path, transformed_source|
location = "#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'"

filtered = BacktraceFilter.new.filter_string(location)

assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered
end
end

test "filter_string maps a transformed file path back to the source file path" do
with_registered_source_map do |source_path, transformed_path, transformed_source|
location = "#{transformed_path}:#{raise_line(transformed_source)}:in 'kaboom'"

filtered = BacktraceFilter.new.filter_string(location)

assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'", filtered
end
end

test "filter_string reports ? for an unmappable line number" do
with_registered_source_map do |source_path, _transformed_path, _transformed_source|
filtered = BacktraceFilter.new.filter_string("#{source_path}:99999:in 'kaboom'")

assert_equal "#{source_path}:?:in 'kaboom'", filtered
end
end

test "filter_string passes through locations without a registered source map" do
location = "/nonexistent/other_file.rb:12:in 'foo'"

assert_equal location, BacktraceFilter.new.filter_string(location)
end

test "filter_exception rewrites the exception backtrace to source locations" do
with_registered_source_map do |source_path, transformed_path, _transformed_source|
load transformed_path
exception = assert_raises(ArgumentError) { BacktraceFilterFixture.new.kaboom }

BacktraceFilter.new.filter_exception(exception)

assert_equal "#{source_path}:#{raise_line(FIXTURE_SOURCE)}", exception.backtrace.first
end
end

test "minitest filter maps mappable lines and passes through the rest" do
# Required here, not at the top: loading this file defines RSpock::Minitest,
# which would shadow ::Minitest for test files nested in module RSpock that
# load after this one. In production the plugin loads at autorun time (after
# all files), which is the timing this mirrors.
require 'rspock/minitest/backtrace_filter'

with_registered_source_map do |source_path, _transformed_path, transformed_source|
backtrace = [
"#{source_path}:#{raise_line(transformed_source)}:in 'kaboom'",
"/nonexistent/other_file.rb:5:in 'x'",
]

filtered = RSpock::Minitest::BacktraceFilter.new.filter(backtrace)

assert_equal [
"#{source_path}:#{raise_line(FIXTURE_SOURCE)}:in 'kaboom'",
"/nonexistent/other_file.rb:5:in 'x'",
], filtered
end
end

private

# Transforms the fixture through the real pipeline, registering its
# SourceMap, and writes both files to disk.
def with_registered_source_map
Dir.mktmpdir do |tmpdir|
# Realpath matters: on macOS mktmpdir yields a /var symlink but
# Thread::Backtrace::Location#absolute_path resolves to /private/var,
# and SourceMap registration is keyed by exact path string.
dir = File.realpath(tmpdir)
source_path = File.join(dir, "backtrace_filter_fixture.rb")
transformed_path = File.join(dir, "backtrace_filter_fixture_transformed.rb")
File.write(source_path, FIXTURE_SOURCE)

transformer = ASTTransform::Transformer.new(ASTTransform::Transformation.new)
transformed_source = transformer.transform_file_source(FIXTURE_SOURCE, source_path, transformed_path)
File.write(transformed_path, transformed_source)

yield source_path, transformed_path, transformed_source
end
end

def raise_line(source)
line_number_of(source, "raise")
end

def line_number_of(source, snippet)
index = source.lines.index { |line| line.include?(snippet) }
flunk "snippet not found in source: #{snippet}" if index.nil?
index + 1
end
end
end
132 changes: 132 additions & 0 deletions test/rspock/backtrace_source_mapping_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'open3'
require 'rspock/declarative'

module RSpock
# End-to-end pin for backtrace source mapping: a failing RSpock test run in a
# subprocess must report line numbers from the file the developer wrote, not
# from the transformed code that actually executes.
#
# The filter-disabled variant documents that the Minitest plugin
# (lib/minitest/rspock_plugin.rb) is what delivers the mapping — ast-transform
# alone makes the *paths* correct but leaves *line numbers* transformed. If
# that test starts failing, the plugin may genuinely be redundant.
class BacktraceSourceMappingTest < ::Minitest::Test
extend RSpock::Declarative

# Blank lines and multi-line expressions make source line numbers diverge
# from transformed ones, so a wrong mapping cannot pass by coincidence.
FIXTURE_SOURCE = <<~RUBY
transform!(RSpock::AST::Transformation)
class SourceMappingFixtureTest < Minitest::Test
test "assertion failure" do
Given "a value built across lines"
value = 1 +
1

When "doubling it"
doubled = value * 2

Then "an expectation that cannot hold"
doubled == 999
end

test "runtime error" do
When "raising mid-stimulus"
raise ArgumentError,
"kaboom"

Then "unreached"
raises RuntimeError
end
end
RUBY

# Ruby subprocesses inherit bundler state pointing at rspock's Gemfile;
# scrub it so the child resolves gems from the parent's live load paths.
# MT_NO_PLUGINS: minitest's gem-scanning plugin discovery can activate a
# second minitest version (the Ruby default gem) over the -I one, breaking
# the run; the runner requires the rspock plugin explicitly instead.
CLEAN_ENV = {
"RUBYOPT" => nil,
"BUNDLE_GEMFILE" => nil,
"BUNDLE_BIN_PATH" => nil,
"MT_NO_PLUGINS" => "1",
}.freeze

test "failure messages and backtraces cite source line numbers" do
output = run_fixture_in_subprocess

assertion_line = line_number_of(FIXTURE_SOURCE, "doubled == 999")
raise_line = line_number_of(FIXTURE_SOURCE, "raise ArgumentError")

assert_includes output, "[test/source_mapping_fixture_test.rb:#{assertion_line}]",
"assertion failure should cite the source line of the failed expectation\n#{output}"
assert_includes output, "test/source_mapping_fixture_test.rb:#{raise_line}:in",
"error backtrace should cite the source line of the raise\n#{output}"
assert_includes output, "2 failures", output
end

test "without the plugin filter, line numbers are transformed — the plugin is still needed" do
with_filter = run_fixture_in_subprocess
without_filter = run_fixture_in_subprocess(disable_plugin_filter: true)

refute_equal cited_line_numbers(with_filter), cited_line_numbers(without_filter),
"disabling the plugin filter no longer changes reported line numbers; " \
"the Minitest plugin may have become redundant\nwith: #{with_filter}\nwithout: #{without_filter}"
end

private

def run_fixture_in_subprocess(disable_plugin_filter: false)
Dir.mktmpdir do |tmpdir|
dir = File.realpath(tmpdir)
FileUtils.mkdir_p(File.join(dir, "test"))
File.write(File.join(dir, "test", "source_mapping_fixture_test.rb"), FIXTURE_SOURCE)
File.write(File.join(dir, "runner.rb"), runner_source(disable_plugin_filter))

output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir)
refute status.success?, "fixture run should fail (it contains failing tests)\n#{output}"

output
end
end

# Plugin discovery is off (MT_NO_PLUGINS), so the enabled variant applies
# the plugin's init by hand — the same call minitest would make; the
# disabled variant simply leaves minitest's default filter in place.
def runner_source(disable_plugin_filter)
install_plugin_filter = <<~RUBY
require "minitest/rspock_plugin"
Minitest.plugin_rspock_init({})
RUBY

<<~RUBY
require "ast_transform"
ASTTransform.install
require "minitest/autorun"
require "rspock"
#{disable_plugin_filter ? "" : install_plugin_filter}
require_relative "test/source_mapping_fixture_test"
RUBY
end

# The child needs the same rspock and dependency load paths as this
# process, without going through bundler.
def load_path_flags
$LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] }
end

def cited_line_numbers(output)
output.scan(%r{test/source_mapping_fixture_test\.rb:(\d+)}).flatten.map(&:to_i).sort.uniq
end

def line_number_of(source, snippet)
index = source.lines.index { |line| line.include?(snippet) }
flunk "snippet not found in source: #{snippet}" if index.nil?
index + 1
end
end
end
Loading