diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ef1de..65e2ce2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.2', '3.3', '4.0'] + ruby: ['3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/.gitignore b/.gitignore index 19257f0..a86f13e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ build-iPhoneSimulator/ .rvmrc # RubyMine -/.idea \ No newline at end of file +/.idea + +# Generated per-machine by `dev up` (d3mlabs dev tool); never committed +/.shadowenv.d/ \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..d13e837 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8814d..891ca23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - Unreleased + +### Fixed + +- Locals assigned in a When block are now readable in Then and Cleanup when the test declares interactions. The thunked When body lowers to a non-lambda proc with method-scope pre-declarations, so `result = subject.call` in When no longer raises `NameError` in a Then expectation or in an ensure-run Cleanup — a long-standing bug of the hoisted-interaction era. + +### Changed + +- Adopted ast_transform 3.0 line-aligned emission: transformed code is emitted with every statement on its original source line, so backtraces, failure messages, breakpoints, and debugger display are source-true natively — with zero runtime filtering. +- Interactions in Then blocks are emitted at their own source lines; the When body they must follow is wrapped in an ast_transform thunk instead of textually hoisting the interaction setups. A `return` inside the thunked body still returns from the test method (proc semantics). +- Where-driven test names now embed the row line via internal block parameters (`__rspock_row_index__` / `__rspock_row_line__`); the appended ` line ` name suffix is unchanged. +- RSpock node classes now register on `ASTTransform::Node` (the promoted registry) instead of RSpock's hand-rolled `Node::REGISTRY`/`NodeBuilder`. +- **Breaking:** dropped Ruby 3.2 support (EOL since March 2026); `required_ruby_version` is now `>= 3.3`. + +### Removed + +- **Breaking:** the `_test_index_` and `_line_number_` test-scope variables. Isolate a Where row by running its generated test by name (the name embeds the row line; the failure output prints the exact rerun command) and break normally — see the README's "Isolating a Where Block row". +- **Breaking:** the Rails generator (`rails g rspock:install`) and its backtrace-cleaner initializer template — there is no backtrace cleaning to configure anymore. +- `RSpock::BacktraceFilter`, `RSpock::Minitest::BacktraceFilter`, and the Minitest plugin (`minitest/rspock_plugin`) — line-aligned emission makes raw VM line numbers the source line numbers, so the whole mapping apparatus is gone. +- The rescue wrapper previously injected around transformed class bodies for backtrace mapping. +- `MethodCallToLVarTransformation` (only existed to service `_test_index_`/`_line_number_`). +- The direct `parser` and `unparser` runtime dependencies: rspock uses both only through ast_transform, which owns their version floors. + +### Added + +- Subprocess acceptance tests pinning line-aligned behavior end to end: Then assertion failures, failing Where rows, interaction setups, and Cleanup failures all cite their own source lines with zero filter machinery; When-assigned locals stay readable in Then and in ensure-run Cleanup when interactions thunk the When body. +- The rspock agent skill (`skills/rspock/SKILL.md`) ships in the gem: the RSpock dialect's mechanics, pitfalls, and debugging triage for coding agents. ## [2.5.0] - 2026-02-28 diff --git a/Gemfile.lock b/Gemfile.lock index a7d6882..6636138 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,22 +1,20 @@ PATH remote: . specs: - rspock (2.5.0) - ast_transform (~> 2.0) + rspock (3.0.0) + ast_transform (~> 3.0) minitest (~> 5.0) mocha (>= 1.0) - parser (>= 3.0) - unparser (>= 0.6) GEM remote: https://rubygems.org/ specs: - ansi (1.5.0) + ansi (1.6.0) ast (2.4.3) - ast_transform (2.1.4) - parser (>= 3.0) + ast_transform (3.0.0) + parser (>= 3.3) prism (>= 1.5) - unparser (>= 0.6) + unparser (>= 0.8) builder (3.3.0) byebug (13.0.0) reline (>= 0.6.0) @@ -26,14 +24,14 @@ GEM io-console (0.8.2) method_source (1.1.0) minitest (5.27.0) - minitest-reporters (1.7.1) + minitest-reporters (1.8.0) ansi builder - minitest (>= 5.0) + minitest (>= 5.0, < 7) ruby-progressbar - mocha (3.0.2) + mocha (3.1.0) ruby2_keywords (>= 0.0.5) - parser (3.3.10.2) + parser (3.3.12.0) ast (~> 2.4.1) racc prism (1.9.0) @@ -45,7 +43,7 @@ GEM byebug (~> 13.0) pry (>= 0.13, < 0.17) racc (1.8.1) - rake (13.3.1) + rake (13.4.2) reline (0.6.3) io-console (~> 0.5) ruby-progressbar (1.13.0) @@ -56,7 +54,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - unparser (0.8.2) + unparser (0.9.0) diff-lcs (>= 1.6, < 3) parser (>= 3.3.0) prism (>= 1.5.1) diff --git a/README.md b/README.md index 7679041..755bfd8 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,6 @@ rakefile = "#{spec.gem_dir}/lib/Rakefile" load rakefile ``` -### Rails - -If you are using Rails, it is necessary to add a filter to *Rails.backtrace_cleaner* for [source mapping](#backtraces) to work, so that you get proper line numbers in Minitest backtraces. For your convenience, we've built a Rails Generator just for that: - - $ rails g rspock:install - -Note: If you are not using Rails, you don't have anything to do, as RSpock includes a Minitest plugin that will set its own backtrace filter. - ## Usage Getting started using RSpock is extremely easy! @@ -297,6 +289,8 @@ This effectively creates one version of the Feature Method for each data row. No You might have noticed above that the test name contains string interpolations, that's one of the features of RSpock! You can interpolate test names and use Where block header variables to parameterize the test name using the test data. +RSpock also appends each data row's index and source line number to the generated test name (e.g. `... 1 line 15`). This keeps names unique for identical data rows and gives the `-n` selector a stable target for isolating a row (see [Isolating a Where Block row](#isolating-a-where-block-row)). + ##### Truth Table Formatting Where Blocks as a truth table is the recommended way to organize your Where Blocks in RSpock. It makes test cases more maintainable since it creates an order between the different test cases and makes it easier to spot missing or duplicated test cases. It also makes it easier to add a column: @@ -586,47 +580,19 @@ This produces tests that are simpler, more explicit, and less coupled to impleme ## Debugging -### Pry - -Let's be honest, at some point you will need to debug your tests. Because RSpock requires transforming the AST, the executed code is slightly different from the source code that you wrote. Although we have plans to add Source Mapping support in Pry so that you can see the exact source code you wrote, this is not currently available. This means that code shown in the Pry console will be slightly different. We think the value of using RSpock greatly outweighs this current limitation. We encourage you to try debugging a test to see what the transformed code looks like, or look through `tmp/rspock` for the transformed files. +Debugging just works. The transformed code is emitted line-aligned — every statement occupies its original source line — so backtraces, failure messages, `break file:line` breakpoints, and debugger display (Pry, byebug, debug.rb) all point at the file you wrote, with no filtering or mapping layer in between. The transformed files under `tmp/ast_transform` are a pure debugging artifact: their line numbers match your source exactly. -### Backtraces +One documented oddity: interaction setups in a `Then` block execute *before* the `When` body (see [Execution Order](#execution-order)), so stepping through a test with interactions jumps from the interaction lines back up to the `When` line once. -RSpock supports Source Mapping so that backtraces for the executed code point to the correct line numbers in your source code, and so that the correct files are referenced. This is achieved through wrapping the executed code in rescue blocks, processing the backtraces (source mapping) and re-raising the error. +### Isolating a Where Block row -### Tips and Tricks +Each `Where` data row generates a separate test whose name embeds the row's index and source line number (e.g. `... 1 line 15`), so isolating a row is standard Minitest: -#### _test_index_ and _line_number_ +* **Newly failing row:** copy the rerun command printed with the failure (the test name embeds the row line), add a plain `binding.pry` (or `debugger`) in the test body, and run it. It fires exactly once, for that row, with the column values inspectable as locals. +* **Chosen row by position:** run with `-n` and a regex on the row's line number from your editor gutter, e.g. `-n /line_15/`. +* **Chosen row by meaning:** use a conditional break on the column locals themselves, e.g. `binding.pry if input == "not json"` — this survives row reordering. -The generated test name for each test case will contain the test index and the line number, corresponding to the Where Block data row for that case, which is available in the test scope as `_test_index_` and `_line_number_` respectively. This can be leveraged to conditionally break on certain test cases, so that you can have a more granular debugging session. - -```ruby -test "Adding #{a} and #{b} results in #{c}" do - When "Adding two numbers" - actual = a + b - - Then "We get the expected result" - # Breaks on the first test case - binding.pry if _test_index_ == 0 - # Breaks on the second test case - binding.pry if _line_number_ == 15 - actual == c - - Where - a | b | c - 1 | 2 | 3 - 4 | 5 | 9 # Line 15 -end -``` - -A few notes: - -* Comparison with `_test_index_` and `_line_number_` is not transformed to assertions in Then and Expect Code Blocks -* `_test_index_` is zero-based, meaning the index of the first test case is `0` - -#### _line_number_ - -The Line number is extremely useful for figuring out exactly which test case failed in your Where Block, especially if you have many rows in your Where Block data table. +Note: a source-line *breakpoint* on a data row cannot isolate that row's run — the Where table is evaluated once, in class scope, when tests are defined. Name-selection is the row-isolation mechanism. ## More info @@ -667,6 +633,8 @@ end After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +If you use the [d3mlabs dev tool](https://github.com/d3mlabs/dev), `dev up` provisions the pinned Ruby (see `.ruby-version`) with a per-project shadowenv, and `dev test` runs the suite — plain Bundler as above works just as well. + To install this gem onto your local machine, run `bundle exec rake install`. ## Releasing a New Version diff --git a/dependencies.rb b/dependencies.rb new file mode 100644 index 0000000..7d83308 --- /dev/null +++ b/dependencies.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Toolchain-only manifest for d3mlabs' dev tool: it provisions this exact +# Ruby (rbenv + shadowenv) for `dev` commands. Gems stay bundler-managed +# through the hand-written gemspec/Gemfile; contributors without dev can +# ignore this file and use .ruby-version. +require "dev/deps" + +Dev::Deps.define do + ruby "4.0.6" +end diff --git a/dev.yml b/dev.yml new file mode 100644 index 0000000..222b798 --- /dev/null +++ b/dev.yml @@ -0,0 +1,8 @@ +name: rspock +commands: + up: + desc: Install gems + run: bundle install + test: + desc: Run this repo's tests + run: bundle exec rake test diff --git a/lib/generators/rspock/install_generator.rb b/lib/generators/rspock/install_generator.rb deleted file mode 100644 index d292c42..0000000 --- a/lib/generators/rspock/install_generator.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -# 's' in Rspock is lowercase on purpose, so generator name is +rspock+ instead of +r_spock+ -module Rspock - module Generators - class InstallGenerator < Rails::Generators::Base - source_root File.expand_path("../../templates", __FILE__) - desc "Creates RSpock initializer for your application" - - def copy_initializer - template "rspock_initializer.rb", "config/initializers/rspock.rb" - - puts "Install complete!" - end - end - end -end diff --git a/lib/generators/templates/rspock_initializer.rb b/lib/generators/templates/rspock_initializer.rb deleted file mode 100644 index 1c8bfb9..0000000 --- a/lib/generators/templates/rspock_initializer.rb +++ /dev/null @@ -1,2 +0,0 @@ -# frozen_string_literal: true -Rails.backtrace_cleaner.add_filter { |line| RSpock::BacktraceFilter.new.filter_string(line) } diff --git a/lib/minitest/rspock_plugin.rb b/lib/minitest/rspock_plugin.rb deleted file mode 100644 index 3799d74..0000000 --- a/lib/minitest/rspock_plugin.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true -require 'rspock/minitest/backtrace_filter' - -module Minitest - def self.plugin_rspock_init(_options) - unless defined?(Rails) - Minitest.backtrace_filter = RSpock::Minitest::BacktraceFilter.new - end - end -end diff --git a/lib/rspock.rb b/lib/rspock.rb index ffc4c81..ae29ae6 100644 --- a/lib/rspock.rb +++ b/lib/rspock.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'rspock/version' -require 'rspock/backtrace_filter' require 'rspock/declarative' require 'ast_transform' diff --git a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb index d00f37d..d78f70f 100644 --- a/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb +++ b/lib/rspock/ast/interaction_to_mocha_mock_transformation.rb @@ -30,7 +30,9 @@ def run(interaction) result = chain_call(interaction.receiver, :expects, s(:sym, interaction.message)) result = chain_call(result, :with, *interaction.args.children) if interaction.args result = build_cardinality(result, interaction.cardinality) - result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) if interaction.outcome + if interaction.outcome + result = chain_call(result, OUTCOME_METHODS.fetch(interaction.outcome.type), *interaction.outcome.children) + end if interaction.block_pass build_block_capture_setup(result, interaction.receiver, interaction.message) diff --git a/lib/rspock/ast/method_call_to_lvar_transformation.rb b/lib/rspock/ast/method_call_to_lvar_transformation.rb deleted file mode 100644 index 960a643..0000000 --- a/lib/rspock/ast/method_call_to_lvar_transformation.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/abstract_transformation' - -module RSpock - module AST - class MethodCallToLVarTransformation < ASTTransform::AbstractTransformation - def initialize(*method_symbols) - @method_call_nodes = method_symbols.map { |method_sym| - s(:send, nil, method_sym) - } - end - - def on_send(node) - return super unless method_call_node?(node) - - node.updated(:lvar, [node.children[1]]) - end - - def method_call_node?(node) - @method_call_nodes.include?(node) - end - end - end -end diff --git a/lib/rspock/ast/node.rb b/lib/rspock/ast/node.rb index 6c48881..3c3e5ef 100644 --- a/lib/rspock/ast/node.rb +++ b/lib/rspock/ast/node.rb @@ -1,22 +1,13 @@ # frozen_string_literal: true -require 'ast_transform/transformation_helper' +require 'ast_transform/node' module RSpock module AST - class Node < ::Parser::AST::Node - REGISTRY = {} - - def self.register(type) - REGISTRY[type] = self - end - - def self.build(type, *children) - klass = REGISTRY[type] || self - klass.new(type, children) - end - end - - class TestNode < Node + # RSpock's intermediate representation: custom node types registered on ASTTransform::Node, so + # +s(:rspock_*, ...)+ constructs these classes with their domain accessors. They exist only between the parser + # and TestMethodTransformation — the transformation lowers every one of them to plain Ruby nodes before + # emission. + class TestNode < ASTTransform::Node register :rspock_test def def_node = children[0] @@ -24,38 +15,38 @@ def body_node = children[1] def where_node = children[2] end - class BodyNode < Node + class BodyNode < ASTTransform::Node register :rspock_body end - class DefNode < Node + class DefNode < ASTTransform::Node register :rspock_def def method_call = children[0] def args = children[1] end - class GivenNode < Node + class GivenNode < ASTTransform::Node register :rspock_given end - class WhenNode < Node + class WhenNode < ASTTransform::Node register :rspock_when end - class ThenNode < Node + class ThenNode < ASTTransform::Node register :rspock_then end - class ExpectNode < Node + class ExpectNode < ASTTransform::Node register :rspock_expect end - class CleanupNode < Node + class CleanupNode < ASTTransform::Node register :rspock_cleanup end - class WhereNode < Node + class WhereNode < ASTTransform::Node register :rspock_where def header @@ -70,7 +61,7 @@ def data_rows end end - class OutcomeNode < Node + class OutcomeNode < ASTTransform::Node end class StubReturnsNode < OutcomeNode @@ -81,7 +72,7 @@ class StubRaisesNode < OutcomeNode register :rspock_stub_raises end - class RaisesNode < Node + class RaisesNode < ASTTransform::Node register :rspock_raises def exception_class = children[0] @@ -89,7 +80,7 @@ def capture_var = children[1] def capture_name = capture_var&.children&.[](0) end - class InteractionNode < Node + class InteractionNode < ASTTransform::Node register :rspock_interaction def cardinality = children[0] @@ -101,7 +92,7 @@ def outcome = children[4] def block_pass = children[5] end - class BinaryStatementNode < Node + class BinaryStatementNode < ASTTransform::Node register :rspock_binary_statement def lhs = children[0] @@ -109,23 +100,11 @@ def operator = children[1] def rhs = children[2] end - class StatementNode < Node + class StatementNode < ASTTransform::Node register :rspock_statement def expression = children[0] def source = children[1] end - - module NodeBuilder - include ASTTransform::TransformationHelper - - def s(type, *children) - if type.to_s.start_with?('rspock_') - Node.build(type, *children) - else - super - end - end - end end end diff --git a/lib/rspock/ast/parser/block.rb b/lib/rspock/ast/parser/block.rb index 738a81e..24a60d1 100644 --- a/lib/rspock/ast/parser/block.rb +++ b/lib/rspock/ast/parser/block.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -7,7 +8,7 @@ module Parser class BlockError < StandardError; end class Block - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper # Constructs a new Block. # diff --git a/lib/rspock/ast/parser/interaction_parser.rb b/lib/rspock/ast/parser/interaction_parser.rb index e768551..f66e20a 100644 --- a/lib/rspock/ast/parser/interaction_parser.rb +++ b/lib/rspock/ast/parser/interaction_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -17,7 +18,7 @@ module Parser # [4] outcome - nil if no >>, otherwise s(:rspock_stub_returns, value) or s(:rspock_stub_raises, *args) # [5] block_pass - nil if no &, otherwise s(:block_pass, ...) class InteractionParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper class InteractionError < RuntimeError; end @@ -34,6 +35,10 @@ def interaction_node?(node) def parse(node) return node unless interaction_node?(node) + # Anchor at the full interaction expression (including >> outcome), so the Mocha setup it lowers into is + # emitted at the interaction's line. + anchor = node + if return_value_node?(node) outcome = parse_outcome(node.children[2]) node = node.children[0] @@ -45,7 +50,7 @@ def parse(node) rhs = node.children[2] receiver, message, args, block_pass = parse_rhs(rhs) - s(:rspock_interaction, + interaction = s(:rspock_interaction, cardinality, receiver, s(:sym, message), @@ -53,6 +58,8 @@ def parse(node) outcome, block_pass ) + + anchor.loc&.expression ? s_at(anchor, interaction.type, *interaction.children) : interaction end private @@ -101,8 +108,8 @@ def validate_cardinality(node) def parse_rhs(node) if node.type == :block - raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions @ #{range(node)}. " \ - "Use &var for block forwarding verification, or << for method body override (future)." + raise InteractionError, "Inline blocks (do...end / { }) are not supported in interactions " \ + "@ #{range(node)}. Use &var for block forwarding verification, or << for method body override (future)." end if node.type != :send diff --git a/lib/rspock/ast/parser/statement_parser.rb b/lib/rspock/ast/parser/statement_parser.rb index df6035f..17bd9f6 100644 --- a/lib/rspock/ast/parser/statement_parser.rb +++ b/lib/rspock/ast/parser/statement_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -10,7 +11,7 @@ module Parser # - Binary operators (==, !=, =~, etc.) become :rspock_binary_statement nodes. # - Everything else becomes :rspock_statement nodes with the original source text captured. class StatementParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper BINARY_OPERATORS = %i[== != =~ !~ > < >= <=].freeze ASSIGNMENT_TYPES = %i[lvasgn masgn op_asgn or_asgn and_asgn].freeze @@ -44,10 +45,10 @@ def build_raises(node) if node.type == :lvasgn variable = s(:sym, node.children[0]) exception_class = node.children[1].children[2] - s(:rspock_raises, exception_class, variable) + s_anchored(node, :rspock_raises, exception_class, variable) else exception_class = node.children[2] - s(:rspock_raises, exception_class) + s_anchored(node, :rspock_raises, exception_class) end end @@ -61,13 +62,20 @@ def binary_statement?(node) BINARY_OPERATORS.include?(node.children[1]) end + # RSpock IR nodes are anchored at the statement they classify, so the assertions they lower into are + # emitted at the statement's source line. def build_binary_statement(node) - s(:rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) + s_anchored(node, :rspock_binary_statement, node.children[0], s(:sym, node.children[1]), node.children[2]) end def build_statement(node) source = node.loc&.expression&.source || node.inspect - s(:rspock_statement, node, s(:str, source)) + s_anchored(node, :rspock_statement, node, s(:str, source)) + end + + # s_at that tolerates loc-less anchors (unit tests build synthetic ASTs). + def s_anchored(anchor, type, *children) + anchor.loc&.expression ? s_at(anchor, type, *children) : s(type, *children) end end end diff --git a/lib/rspock/ast/parser/test_method_parser.rb b/lib/rspock/ast/parser/test_method_parser.rb index 67047a0..d0329c5 100644 --- a/lib/rspock/ast/parser/test_method_parser.rb +++ b/lib/rspock/ast/parser/test_method_parser.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -12,7 +13,7 @@ module Parser # s(:rspock_body, s(:rspock_given, ...), s(:rspock_when, ...), ...), # s(:rspock_where, ...)) # optional class TestMethodParser - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper def initialize(block_registry, strict: true) @block_registry = block_registry @@ -74,7 +75,8 @@ def validate_blocks(blocks, node) end unless blocks.last.can_end? - raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} must be followed by one of these Blocks: #{blocks.last.successors}" + raise BlockError, "Block #{blocks.last.type} @ #{blocks.last.range} " \ + "must be followed by one of these Blocks: #{blocks.last.successors}" end end diff --git a/lib/rspock/ast/statement_to_assertion_transformation.rb b/lib/rspock/ast/statement_to_assertion_transformation.rb index f73dac9..ce417c8 100644 --- a/lib/rspock/ast/statement_to_assertion_transformation.rb +++ b/lib/rspock/ast/statement_to_assertion_transformation.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'ast_transform/transformation_helper' require 'rspock/ast/node' module RSpock @@ -8,7 +9,7 @@ module AST # Binary statements dispatch to specialized assertions (assert_equal, assert_match, assert_operator). # General statements use assert_equal(true/false, expr, source_message) with negation detection. class StatementToAssertionTransformation - include RSpock::AST::NodeBuilder + include ASTTransform::TransformationHelper BINARY_DISPATCH = { :== => :assert_equal, diff --git a/lib/rspock/ast/test_method_def_transformation.rb b/lib/rspock/ast/test_method_def_transformation.rb index f21f864..750ed78 100644 --- a/lib/rspock/ast/test_method_def_transformation.rb +++ b/lib/rspock/ast/test_method_def_transformation.rb @@ -4,15 +4,16 @@ module RSpock module AST + # Appends the data row's index and source line to a Where-driven test's name. The values arrive through + # internal block parameters on the row iterator (see TestMethodTransformation#build_where_args) and surface in + # the test NAME only: the name is what makes identical data rows unique and what the -n selector matches to + # isolate a row. They are deliberately not exposed as test-scope variables. class TestMethodDefTransformation < ASTTransform::AbstractTransformation - TEST_INDEX_AST = s(:begin, - s(:lvar, :_test_index_)) - - LINE_NUMBER_AST = s(:begin, - s(:lvar, :_line_number_)) - - SPACE_STR_AST = s(:str, " ") + ROW_INDEX_ARG = :__rspock_row_index__ + ROW_LINE_ARG = :__rspock_row_line__ + ROW_INDEX_AST = s(:begin, s(:lvar, ROW_INDEX_ARG)) + ROW_LINE_AST = s(:begin, s(:lvar, ROW_LINE_ARG)) LINE_NUMBER_STR_AST = s(:str, " line ") def run(node) @@ -23,7 +24,7 @@ def run(node) def on_str(node) merged = s(:str, "#{node.children[0]} ") - node.updated(:dstr, [merged, TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST]) + node.updated(:dstr, [merged, ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST]) end def on_dstr(node) diff --git a/lib/rspock/ast/test_method_dstr_transformation.rb b/lib/rspock/ast/test_method_dstr_transformation.rb index e1b1795..8b4a41c 100644 --- a/lib/rspock/ast/test_method_dstr_transformation.rb +++ b/lib/rspock/ast/test_method_dstr_transformation.rb @@ -3,15 +3,13 @@ module RSpock module AST + # dstr counterpart of TestMethodDefTransformation: appends the row index and source line interpolations to an + # already-interpolated test name. class TestMethodDstrTransformation < ASTTransform::AbstractTransformation - TEST_INDEX_AST = s(:begin, - s(:lvar, :_test_index_)) - - LINE_NUMBER_AST = s(:begin, - s(:lvar, :_line_number_)) + ROW_INDEX_AST = s(:begin, s(:lvar, :__rspock_row_index__)) + ROW_LINE_AST = s(:begin, s(:lvar, :__rspock_row_line__)) SPACE_STR_AST = s(:str, " ") - LINE_NUMBER_STR_AST = s(:str, " line ") def on_dstr(node) @@ -24,7 +22,7 @@ def on_dstr(node) children << SPACE_STR_AST end - children.push(TEST_INDEX_AST, LINE_NUMBER_STR_AST, LINE_NUMBER_AST) + children.push(ROW_INDEX_AST, LINE_NUMBER_STR_AST, ROW_LINE_AST) node.updated(nil, children) end end diff --git a/lib/rspock/ast/test_method_transformation.rb b/lib/rspock/ast/test_method_transformation.rb index 79cb75c..90e09e2 100644 --- a/lib/rspock/ast/test_method_transformation.rb +++ b/lib/rspock/ast/test_method_transformation.rb @@ -5,7 +5,6 @@ require 'rspock/ast/header_nodes_transformation' require 'rspock/ast/interaction_to_mocha_mock_transformation' require 'rspock/ast/interaction_to_block_identity_assertion_transformation' -require 'rspock/ast/method_call_to_lvar_transformation' require 'rspock/ast/test_method_def_transformation' require 'rspock/ast/parser/test_method_parser' @@ -26,158 +25,208 @@ def run(node) private def transform(rspock_ast) - hoisted_setups = [] - method_call = rspock_ast.def_node.method_call method_args = rspock_ast.def_node.args where = rspock_ast.where_node - transformed_blocks = rspock_ast.body_node.children.map do |block_node| - case block_node.type - when :rspock_then - transform_then_block(block_node, hoisted_setups) - when :rspock_expect - transform_expect_block(block_node) - else - block_node - end + body = build_test_body(rspock_ast.body_node) + build_ruby_ast(method_call, method_args, body, where) + end + + # --- Test body assembly --- + # + # Statements are assembled in SOURCE order so line-aligned emission keeps each one on its own line. + # Execution-order requirements that source order cannot express (interaction setups in Then must run before the + # When body they observe) are carried by ast-transform's thunk facility (run_after / thunk) instead of by + # textual hoisting. + def build_test_body(body_node) + blocks = body_node.children + sections = blocks.map { |block_node| transform_block(block_node) } + + when_statements = statements_of_type(blocks, sections, :rspock_when) + cleanup_statements = statements_of_type(blocks, sections, :rspock_cleanup) + interaction_setups = sections.flat_map(&:interaction_setups) + raises_node = blocks.filter_map { |block_node| find_raises(block_node) }.first + + source_order = blocks.zip(sections).flat_map do |block_node, section| + next [] if block_node.type == :rspock_cleanup + + section.statements.reject { |statement| statement.type == :rspock_raises } end - transformed_body = rspock_ast.body_node.updated(nil, transformed_blocks) - build_ruby_ast(method_call, method_args, transformed_body, where, hoisted_setups) - end + body_children = order_execution(source_order, when_statements, interaction_setups, raises_node) - def transform_then_block(then_node, hoisted_setups) - interaction_setups = [] - then_children = [] + ast = s(:begin, *body_children) + cleanup_statements.empty? ? ast : s(:kwbegin, s(:ensure, ast, s(:begin, *cleanup_statements))) + end - then_node.children.each_with_index do |child, idx| - if child.type == :rspock_interaction - setup = InteractionToMochaMockTransformation.new(idx).run(child) - assertion = InteractionToBlockIdentityAssertionTransformation.new(idx).run(child) + Section = Data.define(:statements, :interaction_setups) - interaction_setups << setup - then_children << assertion unless assertion.equal?(child) - else - then_children << transform_statement_or_passthrough(child) - end + def transform_block(block_node) + case block_node.type + when :rspock_then, :rspock_expect + transform_assertion_block(block_node) + else + Section.new(statements: block_node.children, interaction_setups: []) end + end - unless interaction_setups.empty? - interaction_setups.each do |node| - if node.type == :begin - hoisted_setups.concat(node.children) - else - hoisted_setups << node - end + # Then/Expect children become plain Ruby in place: interactions lower to Mocha setups anchored at the + # interaction's own source line (plus an identity assertion for &block forwarding), statements become + # assertions at their own lines. + # + # Within the section, ALL setups come before all assertions: the thunked When body executes right after the + # last setup, and every assertion (identity or otherwise) observes the When body's effects, so none may + # precede that point. Setups keep source order and their anchors, so alignment holds; assertions after them + # are either synthetic (identity assertions, loc-less, pack anywhere) or textually below the interactions in + # the common case. + def transform_assertion_block(block_node) + setups = [] + assertions = [] + interaction_index = 0 + + block_node.children.each do |child| + case child.type + when :rspock_interaction + interaction_setups, identity_assertions = lower_interaction(child, interaction_index) + interaction_index += 1 + setups.concat(interaction_setups) + assertions.concat(identity_assertions) + when :rspock_binary_statement, :rspock_statement + assertions << anchored_at(child, @statement_transformation.run(child)) + else + assertions << child end end - then_node.updated(nil, then_children) + Section.new(statements: setups + assertions, interaction_setups: setups) end - def transform_expect_block(expect_node) - new_children = expect_node.children.map { |child| transform_statement_or_passthrough(child) } - expect_node.updated(nil, new_children) + # @return [Array(Array, Array)] Mocha setup statements, identity assertions + def lower_interaction(interaction, index) + setup = InteractionToMochaMockTransformation.new(index).run(interaction) + assertion = InteractionToBlockIdentityAssertionTransformation.new(index).run(interaction) + + setups = setup.type == :begin ? setup.children.dup : [setup] + setups[0] = anchored_at(interaction, setups[0]) + + [setups, assertion.equal?(interaction) ? [] : [assertion]] end - def transform_statement_or_passthrough(child) - case child.type - when :rspock_binary_statement, :rspock_statement - @statement_transformation.run(child) + # Reorders execution (not text) where required: + # - interactions without raises: run the When body after the last interaction setup (run_after — the paved + # road). + # - raises without interactions: the When body inlines directly into assert_raises; no thunk needed. + # - raises with interactions: the When body is thunked into the assert_raises block inserted after the last + # setup; the lowering re-emits the body at its own source lines. + def order_execution(source_order, when_statements, interaction_setups, raises_node) + if raises_node + build_raises_body(source_order, when_statements, interaction_setups, raises_node) + elsif interaction_setups.any? && when_statements.any? + run_after(source_order, run: when_statements, after: interaction_setups.last) else - child + source_order end end - # --- Build final Ruby AST --- - - def build_ruby_ast(method_call, method_args, body_node, where, hoisted_setups) - if where - test_def = s(:block, - TestMethodDefTransformation.new.run(method_call), - method_args, - build_test_body(body_node, hoisted_setups) - ) - test_def = HeaderNodesTransformation.new(where.header).run(test_def) + def build_raises_body(source_order, when_statements, interaction_setups, raises_node) + if interaction_setups.any? + assertion = build_assert_raises(raises_node, thunk(*when_statements)) - s(:block, - build_where_iterator(where.data_rows), - build_where_args(where.header), - test_def - ) + reordered = replace_run(source_order, when_statements, []) + insert_after(reordered, interaction_setups.last, assertion) else - s(:block, - method_call, - method_args, - build_test_body(body_node, hoisted_setups) - ) + when_body = when_statements.length == 1 ? when_statements[0] : s(:begin, *when_statements) + assertion = build_assert_raises(raises_node, when_body) + + replace_run(source_order, when_statements, [assertion]) end end - def build_test_body(body_node, hoisted_setups) - body_children = [] - blocks = body_node.children + def build_assert_raises(raises_node, body) + assert_raises_call = s(:block, + s(:send, nil, :assert_raises, raises_node.exception_class), + s(:args), + body + ) - blocks.each_with_index do |block_node, i| - case block_node.type - when :rspock_given - body_children.concat(block_node.children) - when :rspock_when - body_children.concat(hoisted_setups) - raises_node = find_raises_in_next_then(blocks, i) - - if raises_node - body_children << build_assert_raises(block_node, raises_node) - else - body_children.concat(block_node.children) - end - when :rspock_then, :rspock_expect - block_node.children.each do |child| - body_children << child unless child.type == :rspock_raises - end - when :rspock_cleanup - # handled below as ensure - end + if raises_node.capture_name + s(:lvasgn, raises_node.capture_name, assert_raises_call) + else + assert_raises_call end + end - ast = s(:begin, *body_children) + def find_raises(block_node) + return nil unless block_node.type == :rspock_then - cleanup = body_node.children.find { |n| n.type == :rspock_cleanup } - if cleanup && !cleanup.children.empty? - ensure_node = s(:begin, *cleanup.children) - ast = s(:kwbegin, s(:ensure, ast, ensure_node)) - end + block_node.children.find { |child| child.type == :rspock_raises } + end - MethodCallToLVarTransformation.new(:_test_index_, :_line_number_).run(ast) + def statements_of_type(blocks, sections, type) + blocks.zip(sections) + .select { |block_node, _section| block_node.type == type } + .flat_map { |_block_node, section| section.statements } end - # --- Raises condition helpers --- + # --- Identity-based sequence edits (non-thunk counterparts of run_after) --- - def find_raises_in_next_then(blocks, current_index) - next_block = blocks[current_index + 1] - return nil unless next_block&.type == :rspock_then + def replace_run(statements, run, replacement) + start = statements.index { |statement| statement.equal?(run.first) } + raise ArgumentError, "run is not part of statements" unless start - next_block.children.find { |c| c.type == :rspock_raises } + result = statements.dup + result[start, run.length] = replacement + result end - def build_assert_raises(when_node, raises_node) - when_body = when_node.children.length == 1 ? when_node.children[0] : s(:begin, *when_node.children) + def insert_after(statements, anchor, insertion) + index = statements.index { |statement| statement.equal?(anchor) } + raise ArgumentError, "anchor is not part of statements" unless index - assert_raises_call = s(:block, - s(:send, nil, :assert_raises, raises_node.exception_class), - s(:args), - when_body - ) + result = statements.dup + result.insert(index + 1, insertion) + result + end - if raises_node.capture_name - s(:lvasgn, raises_node.capture_name, assert_raises_call) + # Re-anchors +node+ at +anchor+'s source location so emission places it on the anchor's line. + # No-op for anchors without locations. + def anchored_at(anchor, node) + return node unless anchor.loc&.expression + + s_at(anchor, node.type, *node.children) + end + + # --- Build final Ruby AST --- + + def build_ruby_ast(method_call, method_args, body_node, where) + if where + test_def = anchored_at(method_call, s(:block, + TestMethodDefTransformation.new.run(method_call), + method_args, + body_node + )) + test_def = HeaderNodesTransformation.new(where.header).run(test_def) + + s(:block, + build_where_iterator(where.data_rows), + build_where_args(where.header), + test_def + ) else - assert_raises_call + anchored_at(method_call, s(:block, + method_call, + method_args, + body_node + )) end end # --- Where block helpers --- + # + # Each data row carries its source line as a trailing element, surfaced in the generated test NAME only + # (uniqueness for identical rows + the -n selector target) through internal block parameters. There is no + # user-facing runtime variable: isolate a row by running its generated test by name, then break normally. def build_where_iterator(data_rows) s(:send, @@ -192,15 +241,16 @@ def build_where_iterator(data_rows) def build_where_data_row(row) children = row.dup children << s(:int, row.first&.loc&.expression&.line) - s(:array, *children) + anchor = row.first + anchor&.loc&.expression ? s_at(anchor, :array, *children) : s(:array, *children) end def build_where_args(header) injected_args = header.map { |column| s(:arg, column) } - injected_args << s(:arg, :_line_number_) + injected_args << s(:arg, TestMethodDefTransformation::ROW_LINE_ARG) s(:args, s(:mlhs, *injected_args), - s(:arg, :_test_index_), + s(:arg, TestMethodDefTransformation::ROW_INDEX_ARG), ) end end diff --git a/lib/rspock/ast/transformation.rb b/lib/rspock/ast/transformation.rb index 7ec4c4b..2c82b65 100644 --- a/lib/rspock/ast/transformation.rb +++ b/lib/rspock/ast/transformation.rb @@ -77,8 +77,7 @@ def process_casgn_block(node) def process_rspock(node) processed = process_all(node).compact - children = [source_map_rescue_wrapper(s(:begin, *[EXTEND_RSPOCK_DECLARATIVE, *processed]))] - node.updated(nil, children) + node.updated(nil, [EXTEND_RSPOCK_DECLARATIVE, *processed]) end def on_block(node) @@ -91,31 +90,6 @@ def on_block(node) strict: @strict ).run(node) end - - def source_map_rescue_wrapper(node) - s(:kwbegin, - s(:rescue, - node, - s(:resbody, - s(:array, - s(:const, nil, :StandardError) - ), - s(:lvasgn, :e), - s(:begin, - s(:send, - s(:send, - s(:const, - s(:const, - s(:cbase), :RSpock), :BacktraceFilter), :new), :filter_exception, - s(:lvar, :e) - ), - s(:send, nil, :raise) - ) - ), - nil - ) - ) - end end end end diff --git a/lib/rspock/backtrace_filter.rb b/lib/rspock/backtrace_filter.rb deleted file mode 100644 index 135a36b..0000000 --- a/lib/rspock/backtrace_filter.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -require 'ast_transform/source_map' - -module RSpock - class BacktraceFilter - # Constructs a new BacktraceFilter instance. - # - # @param source_map_provider [::ASTTransform::SourceMap] The source map provider to be used. - def initialize(source_map_provider: ::ASTTransform::SourceMap) - @source_map_provider = source_map_provider - end - - # Filters the backtrace of the given +exception+ and applies the filtered backtrace to the exception. - # - # @param exception [Exception] The exception to be filtered. - # - # @return [void] - def filter_exception(exception) - exception.set_backtrace(source_mapped_backtrace(exception)) - end - - # Filters the given location. - # - # @param location [String] A location string. - # - # @return [String] The filtered location. - def filter_string(location) - file_path, lineno = location.match(/([\S]+):(\d+)/).captures - lineno = lineno.to_i - absolute_path = File.expand_path(file_path) - - source_map = @source_map_provider.for_file_path(absolute_path) - return location unless source_map - - line_number = source_map.line(lineno) || '?' - location.sub("#{file_path}:#{lineno}", "#{source_map.source_file_path}:#{line_number}") - end - - private - - def source_mapped_backtrace(e) - e.backtrace_locations&.map(&method(:location_builder)) - end - - def location_builder(location) - source_map = @source_map_provider.for_file_path(location.absolute_path || location.path) - return location.to_s unless source_map - - line_number = source_map.line(location.lineno) || '?' - "#{source_map.source_file_path}:#{line_number}" - end - end -end diff --git a/lib/rspock/minitest/backtrace_filter.rb b/lib/rspock/minitest/backtrace_filter.rb deleted file mode 100644 index 408e5ff..0000000 --- a/lib/rspock/minitest/backtrace_filter.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true -require 'rspock/backtrace_filter' - -module RSpock - module Minitest - class BacktraceFilter - def initialize - @backtrace_filter = RSpock::BacktraceFilter.new - end - - def filter(backtrace) - backtrace.map { |line| @backtrace_filter.filter_string(line) } - end - end - end -end diff --git a/lib/rspock/version.rb b/lib/rspock/version.rb index 5c35291..8b4aae4 100644 --- a/lib/rspock/version.rb +++ b/lib/rspock/version.rb @@ -1,3 +1,3 @@ module RSpock - VERSION = "2.5.0" + VERSION = "3.0.0" end diff --git a/rspock.gemspec b/rspock.gemspec index 1a2c963..400c518 100644 --- a/rspock.gemspec +++ b/rspock.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.required_ruby_version = '>= 3.2' + spec.required_ruby_version = '>= 3.3' # Development dependencies spec.add_development_dependency "bundler", ">= 2.1" @@ -31,9 +31,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency "simplecov", "~> 0.22" # Runtime dependencies - spec.add_runtime_dependency "ast_transform", "~> 2.0" + # parser and unparser are used only through ast_transform, which owns their floors. + spec.add_runtime_dependency "ast_transform", "~> 3.0" spec.add_runtime_dependency "minitest", "~> 5.0" spec.add_runtime_dependency "mocha", ">= 1.0" - spec.add_runtime_dependency "parser", ">= 3.0" - spec.add_runtime_dependency "unparser", ">= 0.6" end diff --git a/skills/rspock/SKILL.md b/skills/rspock/SKILL.md new file mode 100644 index 0000000..ed1234b --- /dev/null +++ b/skills/rspock/SKILL.md @@ -0,0 +1,198 @@ +--- +name: rspock +description: >- + MUST be used when writing or modifying Minitest tests in any repo using + rspock (look for transform!(RSpock::AST::Transformation) in test files or + rspock in the Gemfile). RSpock rewrites test semantics via AST + transformation — code that looks like a no-op statement is an assertion, + and Minitest habits produce silently wrong tests. +--- + +# RSpock: writing tests + +RSpock is a Spock-inspired testing framework on top of Minitest. Tests are +valid Ruby syntax with **different semantics**, applied by AST +transformation at load time. Do not reason about these files as plain +Minitest. + +## The invariant that must never be violated + +**Inside a `transform!(RSpock::AST::Transformation)` class, every bare +statement in a `Then`/`Expect` block IS an assertion. Outside one, it is +NOT — it evaluates and silently discards.** + +Consequences: + +- Inside a `transform!` class, write expression assertions — `a == b` in a + Then/Expect block. The transform compiles them to assertions with proper + failure messages; the Minitest assert API is not the dialect here. +- In a plain Minitest class, use the assert API (`assert_equal` and + friends). A bare comparison there evaluates and discards — a silently + green test. +- When editing a test file, first check for the `transform!` line at each + class definition; it decides which dialect that class speaks. Both + styles may legitimately coexist in one file (see `strict: false` below) + — match the dialect of the class you are in. + +## Boilerplate + +```ruby +require "test_helper" + +transform!(RSpock::AST::Transformation) +class MyThingTest < Minitest::Test + test "descriptive name" do + # code blocks here + end +end +``` + +The application must install the hook once (usually in the test helper): +`require "ast_transform"; ASTTransform.install`. That is the whole setup — +Rails apps need nothing extra (backtraces and debuggers are source-true by +construction; there is no backtrace cleaner to configure). Mixed files can +use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow +plain Minitest tests alongside — this exists to ease gradual migration, +so treat mixed files as normal, not as something to unify. + +The transform is an abstraction — trust it. If you ever need to see the +compiled Ruby (debugging only, never as routine verification), the +transformed files are written under `tmp/ast_transform/`; +they are emitted line-aligned, so their line numbers match your source +exactly. + +## Code blocks and their order + +`Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect` +(stimulus+response in one), plus `Cleanup` (always runs; code defensively +with `&.`) and `Where` (data table, last in source but evaluated first). +Every block takes an optional description string. A `When` is always +followed by a `Then`. Use When+Then for side-effecting code, Expect for +pure functions. + +## Assertion forms (Then/Expect) + +```ruby +Then "the walk produced the right state" +actual == expected # binary operators: == != =~ !~ > < >= <= +list.include?(x) # bare boolean expression asserts +!cart.empty? # negation asserts +name = actual.first # assignments pass through (not assertions) +``` + +LHS is actual, RHS is expected. Exception assertions live in Then, apply +to the preceding When, one per block: + +```ruby +Then "a parse error names the token" +e = raises JSON::ParserError # capture optional +e.message.include?("unexpected token") +``` + +`raises` is not supported in Expect blocks. + +## Where tables (data-driven) + +```ruby +test "adding #{a} and #{b} gives #{c}" do + Expect + a + b == c + + Where + a | b | c + -1 | 1 | 0 + 0 | 0 | 0 + 1 | 2 | 3 +end +``` + +Header names become local variables and interpolate into the test name. +The table is evaluated in class scope — it cannot see instance methods or +test-local variables. Order rows like a truth table; rightmost column is +the expected result. + +To generate an exhaustive table instead of writing it by hand: + +``` +rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'" +``` + +It emits the formatted cross-product (fill the `'?'` column manually). +Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`). +Non-Rails projects must load the gem's Rakefile once to get the task — +see the README's installation section. + +## Interaction mocking (Then only) + +```ruby +Then +1 * subscriber.receive("hello") # exactly one call +0 * mailer.deliver # must never be called +(1..3) * poller.tick # between one and three +(1.._) * poller.tick # at least once +(_..3) * poller.tick # at most three times +_ * cache.fetch("key") >> cached # any count, stubbed return +1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception +1 * ui.frame("Build", &my_block) # block-identity check +``` + +Declared in Then but installed before When runs — declare naturally, +RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` / +`do...end`) are not allowed in interactions — use a named proc with `&`. +Mocks never yield blocks by design: needing that signals the unit under +test is doing too much — restructure so the mock boundary sits between +responsibilities. + +## Debugging failures + +- Backtraces AND debugger display are source-true: transformed code is + emitted with every statement on its original source line, so line + numbers point at the file you wrote with no mapping layer. Trust them; + don't second-guess against `tmp/ast_transform/`. +- `break file:line` binds on user statements; interactive debuggers show + your real source. One documented oddity: interaction setups execute + before the When body, so stepping through a test with interactions + jumps from the interaction lines back up to the When line once. +- To isolate a Where row: the generated test name embeds the row's index + and source line (e.g. `... 1 line 15`). For a newly failing row, copy + the rerun command printed with the failure and add a plain + `binding.pry`. For a chosen row, run `-n /line_15/` with the line from + the editor gutter, or break conditionally on the column locals + themselves (`binding.pry if input == "not json"`). A source-line + breakpoint on a data row cannot isolate its run — the table evaluates + once, in class scope; name-selection is the mechanism. + +## Pitfalls (wrong → right) + +```ruby +# WRONG: Minitest API inside an RSpock class +assert_equal 3, add(1, 2) +# RIGHT +Expect +add(1, 2) == 3 +``` + +```ruby +# WRONG: bare comparison in a class without transform! — silently green +class FooTest < Minitest::Test + test("x") { compute == 42 } +end +# RIGHT: add transform!(RSpock::AST::Transformation) above the class, +# or use assert_equal in plain Minitest +``` + +```ruby +# WRONG: Where table using an instance method for column data +Where +input | expected +helper_val | 1 # NameError: class scope +# RIGHT: use class methods or literals in Where rows +``` + +```ruby +# WRONG: expecting a mocked method to yield +1 * ui.with_spinner("work") { drain(io) } +# RIGHT: restructure — mock boundary between responsibilities +success = drain(io) # test with a real StringIO +1 * ui.ok("work") # simple expectation, no block +``` diff --git a/test/example_rspock_test.rb b/test/example_rspock_test.rb index 044ab38..c486d1f 100644 --- a/test/example_rspock_test.rb +++ b/test/example_rspock_test.rb @@ -161,6 +161,19 @@ def mul(a, b) "hello" | ArgumentError end + test "raises with interactions catches the exception from the deferred When body" do + Given + dep = mock + + When + dep.foo(1) + raise IndexError, "boom" + + Then + 1 * dep.foo(1) + raises IndexError + end + test "interactions" do Given dep = mock diff --git a/test/minitest/reporters/rake_rerun_reporter.rb b/test/minitest/reporters/rake_rerun_reporter.rb index 896d25f..564f313 100644 --- a/test/minitest/reporters/rake_rerun_reporter.rb +++ b/test/minitest/reporters/rake_rerun_reporter.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require 'minitest/reporters' -require 'ast_transform/source_map' module Minitest module Reporters @@ -17,7 +16,8 @@ def record(test) puts puts - puts yellow("You can rerun failed/error test by commands (you can add rerun prefix with 'rerun_prefix' option):") + puts yellow("You can rerun failed/error test by commands " \ + "(you can add rerun prefix with 'rerun_prefix' option):") print_rerun_command(test) puts end @@ -33,8 +33,8 @@ def print_rerun_command(test) end def rerun_message_for(test) + # Line-aligned emission makes backtrace paths the source paths; no mapping needed. file_path = location(test.failure).gsub(/(\:\d*)\z/, "") - file_path = ASTTransform::SourceMap.for_file_path(file_path)&.source_file_path || file_path "Rerun:\n#{@rerun_user_prefix} rake test TEST=#{file_path} TESTOPTS=\"--name=#{test.name} -v\"" end diff --git a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb index d19a225..d515ff3 100644 --- a/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb +++ b/test/rspock/ast/interaction_to_mocha_mock_transformation_test.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require 'test_helper' -require 'string_helper' require 'rspock/ast/parser/interaction_parser' require 'rspock/ast/interaction_to_mocha_mock_transformation' @@ -8,7 +7,6 @@ module RSpock module AST class InteractionToMochaMockTransformationTest < Minitest::Test extend RSpock::Declarative - include RSpock::Helpers::StringHelper include ASTTransform::TransformationHelper def setup @@ -190,31 +188,24 @@ def setup end test "&block produces setup with expects and capture" do - ir_node = parse_to_ir('1 * receiver.message("arg", &my_proc)') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - assert_match(/receiver\.expects\(:message\)\.with\("arg"\)\.times\(1\)/, source) - assert_match(/RSpock::Helpers::BlockCapture\.capture\(receiver, :message\)/, source) + assert_transforms('1 * receiver.message("arg", &my_proc)', <<~RUBY) + receiver.expects(:message).with("arg").times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "&block without other args" do - ir_node = parse_to_ir('1 * receiver.message(&my_proc)') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - refute_match(/\.with\(/, source) - assert_match(/receiver\.expects\(:message\)\.times\(1\)/, source) - assert_match(/BlockCapture\.capture/, source) + assert_transforms('1 * receiver.message(&my_proc)', <<~RUBY) + receiver.expects(:message).times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "&block with >> produces returns and capture" do - ir_node = parse_to_ir('1 * receiver.message(&my_proc) >> "result"') - result = @transformation.run(ir_node) - - source = Unparser.unparse(result) - assert_match(/\.returns\("result"\)/, source) - assert_match(/BlockCapture\.capture/, source) + assert_transforms('1 * receiver.message(&my_proc) >> "result"', <<~RUBY) + receiver.expects(:message).times(1).returns("result") + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "unique index produces unique capture variable names" do @@ -223,8 +214,14 @@ def setup result0 = InteractionToMochaMockTransformation.new(0).run(ir_node) result1 = InteractionToMochaMockTransformation.new(1).run(ir_node) - assert_match(/__rspock_blk_0/, Unparser.unparse(result0)) - assert_match(/__rspock_blk_1/, Unparser.unparse(result1)) + assert_equal <<~RUBY, Unparser.unparse(result0) + receiver.expects(:message).times(1) + __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY + assert_equal <<~RUBY, Unparser.unparse(result1) + receiver.expects(:message).times(1) + __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(receiver, :message) + RUBY end test "non-interaction node is returned unchanged" do @@ -247,7 +244,7 @@ def transform(source) end def assert_transforms(source, expected) - assert_equal strip_end_line(expected + "\n"), transform(source) + assert_equal expected, transform(source) end end end diff --git a/test/rspock/ast/method_call_to_lvar_transformation_test.rb b/test/rspock/ast/method_call_to_lvar_transformation_test.rb deleted file mode 100644 index f42e9a5..0000000 --- a/test/rspock/ast/method_call_to_lvar_transformation_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true -require 'test_helper' -require 'transformation_helper' -require 'rspock/ast/method_call_to_lvar_transformation' - -module RSpock - module AST - class MethodCallToLVarTransformationTest < Minitest::Test - extend RSpock::Declarative - include RSpock::Helpers::TransformationHelper - - test "#run transforms passed symbol calls into lvar" do - transformation = RSpock::AST::MethodCallToLVarTransformation.new(:_test_index_) - - ast = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - actual = transformation.run(ast) - - expected = s(:begin, - s(:send, nil, :a), - s(:lvar, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - assert_equal expected, actual - end - - test "#run does not transform any method calls if no symbols were passed" do - transformation = RSpock::AST::MethodCallToLVarTransformation.new - - ast = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - actual = transformation.run(ast) - - expected = s(:begin, - s(:send, nil, :a), - s(:send, nil, :_test_index_), - s(:send, nil, :b), - s(:send, nil, :c)) - - assert_equal expected, actual - end - end - end -end diff --git a/test/rspock/ast/statement_to_assertion_transformation_test.rb b/test/rspock/ast/statement_to_assertion_transformation_test.rb index 3d20084..6daf253 100644 --- a/test/rspock/ast/statement_to_assertion_transformation_test.rb +++ b/test/rspock/ast/statement_to_assertion_transformation_test.rb @@ -144,11 +144,11 @@ def setup private def build_binary(op, lhs, rhs) - RSpock::AST::Node.build(:rspock_binary_statement, lhs, s(:sym, op), rhs) + s(:rspock_binary_statement, lhs, s(:sym, op), rhs) end def build_statement(expr, source_text) - RSpock::AST::Node.build(:rspock_statement, expr, s(:str, source_text)) + s(:rspock_statement, expr, s(:str, source_text)) end end end diff --git a/test/rspock/ast/test_method_def_transformation_test.rb b/test/rspock/ast/test_method_def_transformation_test.rb index c2ff6ee..7817917 100644 --- a/test/rspock/ast/test_method_def_transformation_test.rb +++ b/test/rspock/ast/test_method_def_transformation_test.rb @@ -22,7 +22,7 @@ def setup assert_same ast, actual end - test "#run transforms str into dstr and injects _test_index_ and _line_number_ lvar" do + test "#run transforms str into dstr and injects __rspock_row_index__ and __rspock_row_line__ lvar" do ast = s(:send, nil, :test, s(:str, "Test Name")) @@ -31,14 +31,14 @@ def setup expected = s(:send, nil, :test, s(:dstr, s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end - test "#run injects _test_index_ and _line_number_ lvar into dstr" do + test "#run injects __rspock_row_index__ and __rspock_row_line__ lvar into dstr" do ast = s(:send, nil, :test, s(:dstr, s(:begin, s(:lvar, :a)), @@ -50,9 +50,9 @@ def setup s(:dstr, s(:begin, s(:lvar, :a)), s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end diff --git a/test/rspock/ast/test_method_dstr_transformation_test.rb b/test/rspock/ast/test_method_dstr_transformation_test.rb index 159d4d9..05cd41a 100644 --- a/test/rspock/ast/test_method_dstr_transformation_test.rb +++ b/test/rspock/ast/test_method_dstr_transformation_test.rb @@ -31,7 +31,7 @@ def setup assert_same ast, actual end - test "#run injects _test_index_ and _line_number_ lvar into dstr" do + test "#run injects __rspock_row_index__ and __rspock_row_line__ lvar into dstr" do ast = s(:send, nil, :test, s(:dstr, s(:begin, s(:lvar, :a)), @@ -43,9 +43,9 @@ def setup s(:dstr, s(:begin, s(:lvar, :a)), s(:str, "Test Name "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end @@ -63,9 +63,9 @@ def setup s(:str, "Test "), s(:begin, s(:lvar, :a)), s(:str, " "), - s(:begin, s(:lvar, :_test_index_)), + s(:begin, s(:lvar, :__rspock_row_index__)), s(:str, " line "), - s(:begin, s(:lvar, :_line_number_)))) + s(:begin, s(:lvar, :__rspock_row_line__)))) assert_equal expected, actual end diff --git a/test/rspock/ast/transformation_test.rb b/test/rspock/ast/transformation_test.rb index 799da93..c8e0c92 100644 --- a/test/rspock/ast/transformation_test.rb +++ b/test/rspock/ast/transformation_test.rb @@ -52,12 +52,12 @@ def setup HEREDOC expected = <<~HEREDOC - test("Adding 1 and 2 results in 3") { + test("Adding 1 and 2 results in 3") do assert_equal(3, 1 + 2) - } + end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "first node cannot be a non-starting block" do @@ -74,6 +74,21 @@ def setup assert_equal "Test method @ tmp:1:1 must start with one of: Given, When, Expect", error.message end + test "test cannot end with a block that requires a successor" do + source = <<~HEREDOC + test "dangling When" do + When "stimulus with no Then" + 1 + 1 + end + HEREDOC + + error = assert_raises RSpock::AST::Parser::BlockError do + transform(source) + end + + assert_equal "Block When @ tmp:2:3 must be followed by one of these Blocks: [:Then]", error.message + end + test "expect block can be followed by nothing" do source = <<~HEREDOC test "Adding \#{a} and \#{b} results in \#{c}" do @@ -183,17 +198,10 @@ def can_end? HEREDOC expected = <<~HEREDOC - Potato = Class.new { - (begin - (extend(RSpock::Declarative)) - rescue StandardError => e - ::RSpock::BacktraceFilter.new.filter_exception(e) - raise - end) - } + Potato = Class.new do; extend(RSpock::Declarative); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "#run adds extend RSpock::Declarative when using traditional class definition" do @@ -204,13 +212,8 @@ class Potato HEREDOC expected = <<~HEREDOC - class Potato - (begin - (extend(RSpock::Declarative)) - rescue StandardError => e - ::RSpock::BacktraceFilter.new.filter_exception(e) - raise - end) + class Potato; extend(RSpock::Declarative) + end HEREDOC @@ -257,6 +260,8 @@ def can_end? end end + # Line-aligned emission: each statement is on its SOURCE line — the When assignment on line 3 (descriptions + # on 2 and 5 vanish into blank lines), the assertion on line 6 where `actual == 3` was written. test "test without where block" do source = <<~HEREDOC test "Adding 1 and 2 results in 3" do @@ -269,13 +274,15 @@ def can_end? HEREDOC expected = <<~HEREDOC - test(\"Adding 1 and 2 results in 3\") { + test("Adding 1 and 2 results in 3") do + actual = 1 + 2 - assert_equal(3, actual) - } + + + assert_equal(3, actual); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with where block" do @@ -295,15 +302,15 @@ def can_end? HEREDOC expected = <<~HEREDOC - [[1, 2, 3, 10], [4, 5, 9, 11]].each.with_index { |(a, b, c, _line_number_), _test_index_| - test("Adding \#{a} and \#{b} results in \#{c} \#{_test_index_} line \#{_line_number_}") { - actual = a + b - assert_equal(c, actual) - } - } + [[1, 2, 3, 10], [4, 5, 9, 11]].each.with_index { |(a, b, c, __rspock_row_line__), __rspock_row_index__|; test("Adding \#{a} and \#{b} results in \#{c} \#{__rspock_row_index__} line \#{__rspock_row_line__}") do + + actual = a + b + + + assert_equal(c, actual); end; } HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with cleanup block" do @@ -322,18 +329,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test(\"Adding 1 and 2 results in 3\") { - begin - actual = 1 + 2 - assert_equal(3, actual) - ensure - method1 - method2 - end - } + test("Adding 1 and 2 results in 3") do; begin + + actual = 1 + 2 + + + assert_equal(3, actual) + ensure + + method1 + method2; end; end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with interactions" do @@ -352,17 +360,92 @@ def can_end? end HEREDOC + # The When body is thunked (interactions must execute first) but keeps its source position; each Mocha + # setup lands on its interaction's line. expected = <<~HEREDOC - test(\"interactions\") { + test("interactions") do + dep = mock - foo = Foo.new(dep) + foo = Foo.new(dep); __ast_thunk_1__ = proc do + + + foo.foo; end + + dep.expects(:bar).times(0) - dep.expects(:foo).times(1) - foo.foo - } + dep.expects(:foo).times(1); __ast_thunk_1__.call; end + HEREDOC + + assert_equal expected, transform(source) + end + + test "test with interactions and a When result read by Then" do + source = <<~HEREDOC + test "when result" do + Given + dep = mock + foo = Foo.new(dep) + + When + result = foo.foo + + Then + 1 * dep.foo + result == 42 + end + HEREDOC + + # `result` is assigned inside the thunk's proc; the lowering pre-declares it (`result = result`) at method + # scope so the assertion after the execution point can read it. + expected = <<~HEREDOC + test("when result") do + + dep = mock + foo = Foo.new(dep); result = result; __ast_thunk_1__ = proc do + + + result = foo.foo; end + + + dep.expects(:foo).times(1); __ast_thunk_1__.call + assert_equal(42, result); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) + end + + test "test with interactions and raises" do + source = <<~HEREDOC + test "raises with interactions" do + Given + dep = mock + foo = Foo.new(dep) + + When + foo.explode + + Then + 1 * dep.foo + raises ExplosionError + end + HEREDOC + + # The thunked When body executes inside assert_raises, which is inserted after the last Mocha setup; + # the raises statement itself contributes no statement of its own. + expected = <<~HEREDOC + test("raises with interactions") do + + dep = mock + foo = Foo.new(dep); __ast_thunk_1__ = proc do + + + foo.explode; end + + + dep.expects(:foo).times(1); assert_raises(ExplosionError) do; __ast_thunk_1__.call; end; end + HEREDOC + + assert_equal expected, transform(source) end test "test with interaction and &block forwarding" do @@ -381,18 +464,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("block forwarding") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:call_method).with("arg").times(1) - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) - dep.call_method("arg", &my_proc) - assert_same(my_proc, __rspock_blk_0.call) - } + test("block forwarding") do + + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do + + + dep.call_method("arg", &my_proc); end + + + dep.expects(:call_method).with("arg").times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with multiple &block interactions" do @@ -414,24 +498,22 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("multiple blocks") { - cb1 = Proc.new { - } - cb2 = Proc.new { - } - dep = mock - dep.expects(:method1).times(1) - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) - dep.expects(:method2).times(1) - __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) + test("multiple blocks") do + + cb1 = Proc.new do; end + cb2 = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do + + dep.method1(&cb1) - dep.method2(&cb2) - assert_same(cb1, __rspock_blk_0.call) - assert_same(cb2, __rspock_blk_1.call) - } + dep.method2(&cb2); end + + + dep.expects(:method1).times(1); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :method1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(cb1, __rspock_blk_0.call); assert_same(cb2, __rspock_blk_1.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with &block and >> return value" do @@ -450,18 +532,19 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("block with return") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:call_method).times(1).returns("result") - __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method) - dep.call_method(&my_proc) - assert_same(my_proc, __rspock_blk_0.call) - } + test("block with return") do + + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do + + + dep.call_method(&my_proc); end + + + dep.expects(:call_method).times(1).returns("result"); __rspock_blk_0 = RSpock::Helpers::BlockCapture.capture(dep, :call_method); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_0.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end test "test with mixed interactions (with and without &block)" do @@ -482,20 +565,21 @@ def can_end? HEREDOC expected = <<~HEREDOC - test("mixed interactions") { - my_proc = Proc.new { - } - dep = mock - dep.expects(:method1).with("arg").times(1) - dep.expects(:method2).times(1) - __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2) + test("mixed interactions") do + + my_proc = Proc.new do; end + dep = mock; __ast_thunk_1__ = proc do + + dep.method1("arg") - dep.method2(&my_proc) - assert_same(my_proc, __rspock_blk_1.call) - } + dep.method2(&my_proc); end + + + dep.expects(:method1).with("arg").times(1) + dep.expects(:method2).times(1); __rspock_blk_1 = RSpock::Helpers::BlockCapture.capture(dep, :method2); __ast_thunk_1__.call; assert_same(my_proc, __rspock_blk_1.call); end HEREDOC - assert_equal strip_end_line(expected), transform(source) + assert_equal expected, transform(source) end private diff --git a/test/rspock/line_alignment_test.rb b/test/rspock/line_alignment_test.rb new file mode 100644 index 0000000..f8d5234 --- /dev/null +++ b/test/rspock/line_alignment_test.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true +require 'test_helper' +require 'tmpdir' +require 'open3' +require 'rspock/declarative' + +module RSpock + # Acceptance tests for line-aligned emission through the full RSpock transformation: raw line numbers — no + # Minitest plugin, no BacktraceFilter, no SourceMap — must already be source line numbers. When these are green, + # the whole mapping apparatus is deletable. + # + # Counterpart of ast-transform's LineAlignmentTest; the fixtures exercise the RSpock dialect specifically + # (Then assertions, Where tables, interactions, Cleanup). + class LineAlignmentTest < ::Minitest::Test + extend RSpock::Declarative + + # Same env scrubbing rationale as SourceTrueBacktraceTest: children inherit bundler state pointing at rspock's + # Gemfile, and minitest's gem-scanning plugin discovery can activate a second minitest version. + CLEAN_ENV = { + "RUBYOPT" => nil, + "BUNDLE_GEMFILE" => nil, + "BUNDLE_BIN_PATH" => nil, + "MT_NO_PLUGINS" => "1", + }.freeze + + THEN_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class ThenFixtureTest < Minitest::Test + test "assertion failure cites its own line" 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 + end + RUBY + + test "Then assertion failure cites the statement's source line, raw" do + output = run_fixture("then_fixture_test.rb", THEN_FIXTURE) + assertion_line = line_number_of(THEN_FIXTURE, "doubled == 999") + + assert_includes output, "test/then_fixture_test.rb:#{assertion_line}", + "raw failure output should cite the assertion's source line\n#{output}" + end + + WHERE_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class WhereFixtureTest < Minitest::Test + test "row \#{a} plus \#{b} makes \#{c}" do + When "adding a and b" + actual = a + b + + Then "matching the expected column" + actual == c + + Where + a | b | c + 1 | 2 | 3 + 4 | 5 | 999 + end + end + RUBY + + test "failing Where row cites the failing statement's source line, raw" do + output = run_fixture("where_fixture_test.rb", WHERE_FIXTURE) + assertion_line = line_number_of(WHERE_FIXTURE, "actual == c") + + assert_includes output, "test/where_fixture_test.rb:#{assertion_line}", + "raw failure output should cite the Then statement's source line\n#{output}" + assert_includes output, "1 failures", output + end + + test "generated test name embeds the failing row's source line" do + output = run_fixture("where_fixture_test.rb", WHERE_FIXTURE) + failing_row_line = line_number_of(WHERE_FIXTURE, "4 | 5 | 999") + + # Minitest sanitizes test names, turning "line 13" into "line_13". + assert_includes output, "line_#{failing_row_line}", + "the failing test's name should embed the data row's source line " \ + "(the row-isolation selector target)\n#{output}" + end + + # The interaction argument expression `Integer("boom")` raises when the Mocha expectation setup executes — + # pinning the line at which interaction setup runs. The setup must execute at the interaction's own source line + # (the When body defers past it), not at the When position interactions used to hoist to. + INTERACTION_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class InteractionFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + end + end + + test "interaction setup executes at its own source line" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + subject.call + + Then "the collaborator was pinged" + 1 * dep.ping(Integer("boom")) + end + end + RUBY + + test "interaction setup execution is observed at the interaction's own source line, raw" do + output = run_fixture("interaction_fixture_test.rb", INTERACTION_FIXTURE) + interaction_line = line_number_of(INTERACTION_FIXTURE, "1 * dep.ping") + + assert_includes output, "test/interaction_fixture_test.rb:#{interaction_line}:in", + "the raise from the interaction's argument expression should cite " \ + "the interaction's source line\n#{output}" + end + + # When the Then block declares interactions, the When body is thunked past the Mocha setups — historically that + # hid `result` (a local first assigned inside the thunk's closure is closure-local), so reading it in Then + # raised NameError. The lowering now pre-declares thunked assignments at method scope; the expectation must + # fail as a plain assertion failure that can SEE the value. + WHEN_RESULT_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class WhenResultFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + 42 + end + end + + test "When result stays readable when interactions defer the When body" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + result = subject.call + + Then "the interaction and an expectation on the result that cannot hold" + 1 * dep.ping(1) + result == 999 + end + end + RUBY + + test "a When-assigned local is readable in Then despite interaction deferral, raw" do + output = run_fixture("when_result_fixture_test.rb", WHEN_RESULT_FIXTURE) + assertion_line = line_number_of(WHEN_RESULT_FIXTURE, "result == 999") + + # A failure (not an error): a NameError on `result` would be an error. + assert_includes output, "1 failures, 0 errors", + "the result expectation should fail as an assertion, proving `result` was readable\n#{output}" + assert_includes output, "42", + "the failure message should show the deferred When's actual result\n#{output}" + assert_includes output, "test/when_result_fixture_test.rb:#{assertion_line}", + "the failing expectation should cite its source line\n#{output}" + end + + CLEANUP_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class CleanupFixtureTest < Minitest::Test + test "cleanup failure cites its own line" do + Given "a resource" + resource = Object.new + + Expect "a passing expectation" + resource.frozen? == false + + Cleanup "raising during teardown" + raise "cleanup boom" + end + end + RUBY + + test "Cleanup-block failure cites the cleanup statement's source line, raw" do + output = run_fixture("cleanup_fixture_test.rb", CLEANUP_FIXTURE) + cleanup_raise_line = line_number_of(CLEANUP_FIXTURE, 'raise "cleanup boom"') + + assert_includes output, "test/cleanup_fixture_test.rb:#{cleanup_raise_line}", + "the cleanup raise should cite its source line\n#{output}" + end + + # The compound of the two hazards above: Cleanup runs inside ensure, so it observes the When local BOTH after + # a failing Then AND with the When body deferred behind interaction setups. Historically this raised NameError + # inside ensure, masking the real failure. + CLEANUP_WHEN_LOCAL_FIXTURE = <<~RUBY + transform!(RSpock::AST::Transformation) + class CleanupWhenLocalFixtureTest < Minitest::Test + class Subject + def initialize(dep) + @dep = dep + end + + def call + @dep.ping(1) + :open + end + end + + test "Cleanup sees the When-assigned local" do + Given "a mocked collaborator" + dep = mock + subject = Subject.new(dep) + + When "exercising the subject" + handle = subject.call + + Then "an interaction and an expectation that cannot hold" + 1 * dep.ping(1) + handle == :closed + + Cleanup "reporting what the ensure block can see" + puts "cleanup saw \#{handle.inspect}" + end + end + RUBY + + test "Cleanup reads a When-assigned local after a failing Then, raw" do + output = run_fixture("cleanup_when_local_fixture_test.rb", CLEANUP_WHEN_LOCAL_FIXTURE) + assertion_line = line_number_of(CLEANUP_WHEN_LOCAL_FIXTURE, "handle == :closed") + + assert_includes output, "cleanup saw :open", + "the ensure-run Cleanup should see the When-assigned local\n#{output}" + assert_includes output, "1 failures, 0 errors", + "the Then failure should surface as an assertion failure, not be masked by a NameError in ensure\n#{output}" + assert_includes output, "test/cleanup_when_local_fixture_test.rb:#{assertion_line}", + "the failing expectation should cite its source line\n#{output}" + end + + private + + # Runs +fixture_source+ in a subprocess with NO backtrace filtering of any kind: plugin discovery is off and + # the rspock plugin is never installed. Whatever line numbers appear in the output are the VM's raw truth. + def run_fixture(file_name, fixture_source) + Dir.mktmpdir do |tmpdir| + dir = File.realpath(tmpdir) + FileUtils.mkdir_p(File.join(dir, "test")) + File.write(File.join(dir, "test", file_name), fixture_source) + File.write(File.join(dir, "runner.rb"), <<~RUBY) + require "ast_transform" + ASTTransform.install + require "minitest/autorun" + require "mocha/minitest" + require "rspock" + require_relative "test/#{file_name.delete_suffix('.rb')}" + RUBY + + output, status = Open3.capture2e(CLEAN_ENV, RbConfig.ruby, *load_path_flags, "runner.rb", chdir: dir) + refute status.success?, "fixture run should fail (it contains a failing test)\n#{output}" + + output + end + end + + def load_path_flags + $LOAD_PATH.grep(%r{/(lib|gems)(/|\z)}).flat_map { |path| ["-I", path] } + 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/source_true_backtrace_test.rb b/test/rspock/source_true_backtrace_test.rb new file mode 100644 index 0000000..35b16f0 --- /dev/null +++ b/test/rspock/source_true_backtrace_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true +require 'test_helper' +require 'tmpdir' +require 'open3' +require 'rspock/declarative' + +module RSpock + # End-to-end pin for source-true backtraces: a failing RSpock test run in a subprocess must report + # line numbers from the file the developer wrote — with NO filtering machinery of any kind. + # Line-aligned emission places each transformed statement on its source line, so the VM's raw line + # numbers are already the source line numbers; there is no plugin, no BacktraceFilter, and no + # SourceMap left to install. + class SourceTrueBacktraceTest < ::Minitest::Test + extend RSpock::Declarative + + # The blank lines and split expressions (`1 +` / `raise ArgumentError,`) are load-bearing: a + # naive unparse would compress them, shifting every later statement's line number. Without them, + # even a non-aligned emitter would happen to reproduce the source line numbers, and this test + # would pass without proving alignment. + FIXTURE_SOURCE = <<~RUBY + transform!(RSpock::AST::Transformation) + class BacktraceFixtureTest < 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. Nothing rspock-specific needs plugging in anymore. + CLEAN_ENV = { + "RUBYOPT" => nil, + "BUNDLE_GEMFILE" => nil, + "BUNDLE_BIN_PATH" => nil, + "MT_NO_PLUGINS" => "1", + }.freeze + + test "raw failure messages and backtraces cite source line numbers, with zero filter machinery" 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/backtrace_fixture_test.rb:#{assertion_line}]", + "assertion failure should cite the source line of the failed expectation\n#{output}" + assert_includes output, "test/backtrace_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 + + private + + def run_fixture_in_subprocess + Dir.mktmpdir do |tmpdir| + dir = File.realpath(tmpdir) + FileUtils.mkdir_p(File.join(dir, "test")) + File.write(File.join(dir, "test", "backtrace_fixture_test.rb"), FIXTURE_SOURCE) + File.write(File.join(dir, "runner.rb"), <<~RUBY) + require "ast_transform" + ASTTransform.install + require "minitest/autorun" + require "rspock" + require_relative "test/backtrace_fixture_test" + RUBY + + 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 + + # 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 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