diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8814d..560549e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/test/rspock/backtrace_filter_test.rb b/test/rspock/backtrace_filter_test.rb new file mode 100644 index 0000000..b423df0 --- /dev/null +++ b/test/rspock/backtrace_filter_test.rb @@ -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 diff --git a/test/rspock/backtrace_source_mapping_test.rb b/test/rspock/backtrace_source_mapping_test.rb new file mode 100644 index 0000000..bb70b31 --- /dev/null +++ b/test/rspock/backtrace_source_mapping_test.rb @@ -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