diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1cac895 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.2', '3.3', '3.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Run StandardRB + run: bundle exec standardrb + + - name: Run tests + run: bundle exec rspec + env: + COVERAGE: true + + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report-${{ matrix.ruby-version }} + path: coverage/ diff --git a/.gitignore b/.gitignore index 743c196..9047b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,13 +21,20 @@ # Other gems for AI context /diffy/ /super_diff/ +/fuzzy_match_poc/ +/oj/ +/parallel_tests/ +/amazing_print/ # Bundler Gemfile.lock -# Test output files (from old complex demo) +# Test output files +*.json enriched_output.json standard_output.json +builtin_json_output.json +vanilla_output.json # IDE .idea/ @@ -36,3 +43,4 @@ standard_output.json *.swo *~ .DS_Store +.claude/ diff --git a/.standard.yml b/.standard.yml index 83761c9..8c71b19 100644 --- a/.standard.yml +++ b/.standard.yml @@ -4,3 +4,7 @@ ignore: - 'spec/edge_cases_spec.rb:40' - 'spec/safety_limits_spec.rb:52' - 'rspec/**/*' + - 'fuzzy_match_poc/**/*' + - 'oj/**/*' + - 'diffy/**/*' + - 'super_diff/**/*' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba5004..68cae83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2025-07-18 + +### Added +- Add `negated` flag to detect when `not_to` or `to_not` is used +- Add `passed` field to distinguish passing from failing tests in captured values + +### Changed +- Major code simplification - removed unnecessary abstractions and comments +- Updated documentation to reflect actual features (removed non-existent performance limits) +- **BREAKING**: No longer removes diff from exception messages - now a true drop-in replacement + +### Fixed +- Fixed spec files that were causing false CI failures +- Updated integration tests to match current behavior + +### Removed +- Removed automatic diff stripping from exception messages (introduced in 0.6.1) + +## [0.6.1] - 2025-07-18 + +### Added +- Capture expected/actual values for passing tests (not just failures) +- Memory-safe implementation with cleanup after formatter completes +- Special handling for Regexp serialization (human-readable format like `/pattern/flags`) +- Comprehensive test coverage for new features + +### Fixed +- Fixed key mismatch bug between storage and retrieval of test values +- Fixed double-encoding issue in formatter that caused escaped strings in output + +### Changed +- Upgraded to Oj for JSON serialization (better performance and object handling) +- Improved error handling with detailed fallback information + ## [0.5.0] - 2025-06-26 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b6593ec..d6a1153 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ This project creates a universal JSON output system for RSpec matchers: - `lib/rspec/enriched_json/formatters/enriched_json_formatter.rb` - JSON formatter that outputs enriched data - `rspec-enriched_json.gemspec` - Gem specification - `spec/` - Test suite with integration and unit tests -- `demo_all_failures.rb` - Demo script showing various failure types +- `demo.rb` - Demo script showing various failure types - `Gemfile` - Dependencies (rspec, standard) - `.standard.yml` - StandardRB configuration diff --git a/README.md b/README.md index eff0199..f4ca849 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # RSpec::EnrichedJson +[![CI](https://github.com/firstdraft/rspec-enriched_json/actions/workflows/ci.yml/badge.svg)](https://github.com/firstdraft/rspec-enriched_json/actions/workflows/ci.yml) +[![Gem Version](https://badge.fury.io/rb/rspec-enriched_json.svg)](https://badge.fury.io/rb/rspec-enriched_json) +[![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/standardrb/standard) + A drop-in replacement for RSpec's built-in JSON formatter that enriches the output with structured failure data. This makes it easy to programmatically analyze test results, extract expected/actual values, and build better CI/CD integrations. ## Quick Demo @@ -30,7 +34,7 @@ This interactive demo script runs the same failing tests with both formatters an Add this line to your application's Gemfile: ```ruby -gem 'rspec-enriched_json' +gem "rspec-enriched_json" ``` And then execute: @@ -95,14 +99,14 @@ With this gem, you get structured data alongside the original message: ## Features -- **Drop-in replacement**: Inherits from RSpec's JsonFormatter, maintaining 100% compatibility -- **Structured data extraction**: Expected and actual values as proper JSON objects -- **Rich object support**: Arrays, hashes, and custom objects are properly serialized -- **Original message preservation**: When you override with a custom message, the original is preserved -- **Graceful degradation**: Regular exceptions (non-expectation failures) work normally -- **Enhanced metadata capture**: Test location, tags, hierarchy, and custom metadata -- **Robust error recovery**: Handles objects that fail to serialize without crashing -- **Diff information**: Includes `diffable` to help tools determine if values can be meaningfully diffed +- **Drop-in replacement**: Inherits from RSpec's JsonFormatter, maintaining 100% compatibility. +- **Structured data extraction**: Expected and actual values as proper JSON objects. +- **Rich object support**: Arrays, hashes, and custom objects are properly serialized. +- **Original message preservation**: When you override with a custom message, the original is preserved. +- **Graceful degradation**: Regular exceptions (non-expectation failures) work normally. +- **Enhanced metadata capture**: Test location, tags, hierarchy, and custom metadata. +- **Robust error recovery**: Handles objects that fail to serialize without crashing. +- **Diff information**: Includes `diffable` to help tools determine if values can be meaningfully diffed. ## Examples @@ -147,36 +151,50 @@ end ## Use Cases -- **CI/CD Integration**: Parse test results to create rich error reports -- **Test Analytics**: Track which values commonly cause test failures -- **Debugging Tools**: Build tools that can display expected vs actual diffs -- **Learning Platforms**: Provide detailed feedback on why tests failed +- **CI/CD Integration**: Parse test results to create rich error reports. +- **Test Analytics**: Track which values commonly cause test failures. +- **Debugging Tools**: Build tools that can display expected vs actual diffs. +- **Learning Platforms**: Provide detailed feedback on why tests failed. ## How It Works The gem works by: -1. Patching RSpec's expectation system to capture structured data when expectations fail -2. Extending the JsonFormatter to include this data in the JSON output -3. Maintaining full backward compatibility with existing tools +1. Patching RSpec's expectation system to capture structured data when expectations fail. +2. Extending the JsonFormatter to include this data in the JSON output. +3. Maintaining full backward compatibility with existing tools. ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. -## Performance Considerations +This project uses [StandardRB](https://github.com/standardrb/standard) for code formatting and style. Before committing: + +```bash +# Check for style violations +bundle exec standardrb + +# Auto-fix style violations +bundle exec standardrb --fix +``` + +## Additional Features + +### Passing Test Value Capture +The formatter also captures expected/actual values for passing tests, useful for: +- Test analytics and insights. +- Understanding test coverage patterns. +- Debugging flaky tests. -The enriched formatter adds minimal overhead: -- Only processes failing tests (passing tests have no extra processing) -- Limits serialization depth to prevent infinite recursion -- Truncates large strings and collections to maintain reasonable output sizes -- No impact on test execution time, only on failure reporting +### Negation Detection +Tests using `not_to` or `to_not` include a `negated: true` flag in the details. -Default limits: -- Max serialization depth: 5 levels -- Max array size: 100 items -- Max hash size: 100 keys -- Max string length: 1000 characters +### Serialization +Values are serialized using [Oj](https://github.com/ohler55/oj) in object mode, providing: +- Circular reference handling. +- Proper Ruby object serialization. +- Excellent performance. +- Special handling for Regexp objects (serialized as inspect strings). ## Contributing diff --git a/TODOS.md b/TODOS.md deleted file mode 100644 index 4f72e8d..0000000 --- a/TODOS.md +++ /dev/null @@ -1,161 +0,0 @@ -# TODOs for RSpec::EnrichedJson - -## Completed Features - -- [x] **Structured Data Extraction** - - Expected and actual values are captured as proper JSON objects (not strings) - - Works with all RSpec matchers through universal wrapper approach - - Graceful degradation for matchers without expected/actual methods - -- [x] **Matcher Name Capture** - - Full matcher class name included (e.g., "RSpec::Matchers::BuiltIn::Eq") - - Available for all matchers that fail - -- [x] **Rich Object Serialization** - - Arrays and hashes properly serialized with safety limits - - Custom objects include class name, instance variables, and string representations - - Special handling for Structs with `struct_values` field - - Performance limits: max depth (5), max array/hash size (100), max string length (1000) - -- [x] **Original Message Preservation** - - When custom failure messages are provided, original matcher message is preserved - - Available in `original_message` field of structured data - -## High Priority Improvements - -- [ ] **Add Configuration Options** - - Allow customization of serialization limits (max_depth, max_array_size, max_string_length) - - Toggle inclusion of metadata, timestamps, backtrace - - Provide sensible defaults with ability to override - ```ruby - RSpec::EnrichedJson.configure do |config| - config.max_depth = 10 - config.max_string_length = 5000 - config.include_metadata = true - config.include_timestamps = true - end - ``` - -- [ ] **Support Aggregate Failures** - - Capture all failures in aggregate_failures blocks, not just the first - - Structure output to include array of failures - - Critical for modern test suites using aggregate_failures - -- [x] **Add Metadata Capture** - - File path and line numbers - - Custom tags (`:focus`, `:slow`, `:db`, `:priority`, etc.) - - Example group hierarchy - - Test IDs for re-running specific tests - - Described class information - -- [ ] **Smart ActiveRecord/ActiveModel Handling** - - Special serialization for Rails models - - Extract attributes instead of just inspect string - - Handle associations intelligently - - Avoid N+1 serialization issues - -## Medium Priority Improvements - -- [x] **Better Error Recovery** - - Graceful handling when inspect/to_s raises errors - - Provide helpful context about serialization failures - - Include fallback values and error reasons - ```ruby - { - "serialization_error": true, - "reason": "Circular reference detected", - "class": "User", - "fallback_value": "#" - } - ``` - -- [ ] **Thread Safety** - - Ensure wrapper works correctly with parallel test execution - - Test with parallel_tests gem - - Document thread safety guarantees - -- [ ] **Performance Monitoring** - - Optional capture of started_at/finished_at timestamps - - Memory usage tracking (if enabled) - - Performance impact documentation - -- [ ] **Smart Serialization Improvements** - - Better handling of Date/Time objects (ISO8601 format) - - Support for custom serializers per class - - Handle binary data gracefully - - Deal with mixed encoding issues - -- [ ] **Multiple Output Formats** - - Minimal format for CI (essential data only) - - Full format for debugging (all available data) - - Allow format selection via command line - -## Low Priority Improvements - -- [ ] **Integration Helpers** - - CI/CD annotation generators (GitHub, GitLab, etc.) - - HTML/Markdown report generators - - Example parsers in multiple languages - -- [ ] **Better Installation Experience** - - Auto-configuration helper - - CLI shortcuts (`--format enriched`) - - Migration guide from other formatters - -- [ ] **Enhanced Documentation** - - Comprehensive CI/CD integration guide - - Performance benchmarks vs vanilla formatter - - Troubleshooting guide for common issues - - Example JSON parsing in Ruby, Python, JavaScript - -- [ ] **Streaming Support** - - For very large test suites - - Reduce memory usage - - Progressive output - -## Nice to Have Features - -- [ ] **SimpleCov Integration** - - Include coverage data in output - - Link failures to uncovered code - -- [ ] **Spring/Zeus Compatibility** - - Test and ensure compatibility - - Document any special configuration needed - -- [ ] **RSpec Bisect Support** - - Ensure formatter works with RSpec's bisect command - - Add bisect-specific data if helpful - -- [ ] **Custom Matcher Support Guide** - - Documentation for making custom matchers work well with enriched output - - Best practices for expected/actual methods - -## Technical Debt - -- [ ] **Add More Integration Tests** - - Test with various RSpec configurations - - Test with popular RSpec extensions - - Test with different Ruby versions - -- [ ] **Performance Optimization** - - Profile serialization code - - Add caching where appropriate - - Benchmark against vanilla formatter - -- [ ] **Code Organization** - - Consider splitting large files - - Extract serialization strategies - - Improve module structure - -## Backward Compatibility - -- [ ] **Version Output Format** - - Add version field to JSON output - - Plan for future breaking changes - - Document upgrade paths - -- [ ] **Maintain Compatibility** - - All new fields should be opt-in - - Existing structure must remain unchanged - - Deprecation strategy for future changes \ No newline at end of file diff --git a/analyze_builtin_json.rb b/analyze_builtin_json.rb deleted file mode 100644 index 4cda15b..0000000 --- a/analyze_builtin_json.rb +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env ruby -# Script to demonstrate how RSpec's built-in JSON formatter handles custom failure messages - -require "tempfile" -require "json" - -# Create a temporary test file -test_code = <<~RUBY - RSpec.describe "Custom Message Test" do - it "fails with custom message provided" do - balance = 50 - required = 100 - expect(balance).to be >= required, "Insufficient funds: need \#{required} but only have \#{balance}" - end - - it "fails without custom message" do - expect(2).to eq(3) - end - end -RUBY - -# Write to temp file -test_file = Tempfile.new(["test_", ".rb"]) -test_file.write(test_code) -test_file.close - -# Run RSpec with built-in JSON formatter -# Use system ruby to avoid bundler loading enriched_json -full_output = `/usr/bin/ruby -e "require 'rspec'; load '#{test_file.path}'; RSpec.configure {|c| c.formatter = 'json'}; RSpec::Core::Runner.run([])" 2>&1` - -puts "Full output:" -puts full_output -puts "\n" + "=" * 50 - -# Extract just the JSON line (last line) -json_output = full_output.split("\n").last - -puts "JSON line:" -puts json_output -puts "\n" + "=" * 50 - -# Parse and display the JSON -begin - result = JSON.parse(json_output) - - puts "RSpec Built-in JSON Formatter Output Analysis:" - puts "=" * 50 - - result["examples"].each_with_index do |example, i| - puts "\nExample #{i + 1}: #{example["description"]}" - puts "Status: #{example["status"]}" - - if example["exception"] - puts "Exception Class: #{example["exception"]["class"]}" - puts "Exception Message:" - puts example["exception"]["message"].split("\n").map { |line| " #{line}" }.join("\n") - end - end - - puts "\n" + "=" * 50 - puts "\nKey Observation:" - puts "Custom failure messages in RSpec are embedded within the exception message string," - puts "not as a separate field in the JSON output." -ensure - test_file.unlink -end diff --git a/bin/console b/bin/console index ba2a729..3f82b1b 100755 --- a/bin/console +++ b/bin/console @@ -1,10 +1,10 @@ #! /usr/bin/env ruby # frozen_string_literal: true -require 'bundler/setup' +require "bundler/setup" Bundler.require :tools -require 'rspec/enriched_json' -require 'irb' +require "rspec/enriched_json" +require "irb" IRB.start __FILE__ diff --git a/bin/rake b/bin/rake index 7f348eb..9bef742 100755 --- a/bin/rake +++ b/bin/rake @@ -1,6 +1,6 @@ #! /usr/bin/env ruby # frozen_string_literal: true -require 'bundler/setup' +require "bundler/setup" -load Gem.bin_path 'rake', 'rake' +load Gem.bin_path "rake", "rake" diff --git a/bin/rspec b/bin/rspec index a73453e..328edde 100755 --- a/bin/rspec +++ b/bin/rspec @@ -1,6 +1,6 @@ #! /usr/bin/env ruby # frozen_string_literal: true -require 'bundler/setup' +require "bundler/setup" -load Gem.bin_path 'rspec-core', 'rspec' +load Gem.bin_path "rspec-core", "rspec" diff --git a/bin/rubocop b/bin/rubocop index 7e7785d..fcc59f5 100755 --- a/bin/rubocop +++ b/bin/rubocop @@ -1,6 +1,6 @@ #! /usr/bin/env ruby # frozen_string_literal: true -require 'bundler/setup' +require "bundler/setup" -load Gem.bin_path 'rubocop', 'rubocop' +load Gem.bin_path "rubocop", "rubocop" diff --git a/bin/setup b/bin/setup index 3714742..988e359 100755 --- a/bin/setup +++ b/bin/setup @@ -1,17 +1,17 @@ #! /usr/bin/env ruby # frozen_string_literal: true -require 'debug' -require 'fileutils' -require 'pathname' +require "debug" +require "fileutils" +require "pathname" -APP_ROOT = Pathname(__dir__).join('..').expand_path +APP_ROOT = Pathname(__dir__).join("..").expand_path Runner = lambda do |*arguments, kernel: Kernel| kernel.system(*arguments) || kernel.abort("\nERROR: Command #{arguments.inspect} failed.") end FileUtils.chdir APP_ROOT do - puts 'Installing dependencies...' - Runner.call 'bundle install' + puts "Installing dependencies..." + Runner.call "bundle install" end diff --git a/builtin_json_output.json b/builtin_json_output.json deleted file mode 100644 index 12b9d94..0000000 --- a/builtin_json_output.json +++ /dev/null @@ -1,85 +0,0 @@ - -Randomized with seed 29230 - -Built-in JSON Formatter Custom Message Test - fails without custom message (FAILED - 1) - fails with a simple custom message (FAILED - 2) - fails with hash expectation and custom message (FAILED - 3) - -Failures: - - 1) Built-in JSON Formatter Custom Message Test fails without custom message - Failure/Error: raise EnrichedExpectationNotMetError.new(e.message, structured_data) - - expected: 3 - got: 2 - - (compared using ==) - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - # ./test_builtin_json_custom_message.rb:11:in 'block (2 levels) in ' - # ------------------ - # --- Caused by: --- - # - # expected: 3 - # got: 2 - # - # (compared using ==) - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - - 2) Built-in JSON Formatter Custom Message Test fails with a simple custom message - Failure/Error: raise EnrichedExpectationNotMetError.new(e.message, structured_data) - - expected: >= 100 - got: 50 - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - # ./test_builtin_json_custom_message.rb:7:in 'block (2 levels) in ' - # ------------------ - # --- Caused by: --- - # expected: >= 100 - # got: 50 - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - - 3) Built-in JSON Formatter Custom Message Test fails with hash expectation and custom message - Failure/Error: raise EnrichedExpectationNotMetError.new(e.message, structured_data) - - expected: {body: "Not Found", status: 404} - got: {body: "OK", status: 200} - - (compared using ==) - - Diff: - @@ -1,2 +1,2 @@ - -:body => "Not Found", - -:status => 404, - +:body => "OK", - +:status => 200, - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - # ./test_builtin_json_custom_message.rb:17:in 'block (2 levels) in ' - # ------------------ - # --- Caused by: --- - # - # expected: {body: "Not Found", status: 404} - # got: {body: "OK", status: 200} - # - # (compared using ==) - # - # Diff: - # @@ -1,2 +1,2 @@ - # -:body => "Not Found", - # -:status => 404, - # +:body => "OK", - # +:status => 200, - # ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure' - -Finished in 0.00555 seconds (files took 0.0381 seconds to load) -3 examples, 3 failures - -Failed examples: - -rspec ./test_builtin_json_custom_message.rb:10 # Built-in JSON Formatter Custom Message Test fails without custom message -rspec ./test_builtin_json_custom_message.rb:4 # Built-in JSON Formatter Custom Message Test fails with a simple custom message -rspec ./test_builtin_json_custom_message.rb:14 # Built-in JSON Formatter Custom Message Test fails with hash expectation and custom message - -Randomized with seed 29230 - -{"version":"3.13.5","seed":29230,"examples":[{"id":"./test_builtin_json_custom_message.rb[1:2]","description":"fails without custom message","full_description":"Built-in JSON Formatter Custom Message Test fails without custom message","status":"failed","file_path":"./test_builtin_json_custom_message.rb","line_number":10,"run_time":0.004486,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"\nexpected: 3\n got: 2\n\n(compared using ==)\n","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_builtin_json_custom_message.rb:11:in 'block (2 levels) in '","------------------","--- Caused by: ---"," "," expected: 3"," got: 2"," "," (compared using ==)"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}},{"id":"./test_builtin_json_custom_message.rb[1:1]","description":"fails with a simple custom message","full_description":"Built-in JSON Formatter Custom Message Test fails with a simple custom message","status":"failed","file_path":"./test_builtin_json_custom_message.rb","line_number":4,"run_time":0.000305,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"expected: >= 100\n got: 50","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_builtin_json_custom_message.rb:7:in 'block (2 levels) in '","------------------","--- Caused by: ---"," expected: >= 100"," got: 50"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}},{"id":"./test_builtin_json_custom_message.rb[1:3]","description":"fails with hash expectation and custom message","full_description":"Built-in JSON Formatter Custom Message Test fails with hash expectation and custom message","status":"failed","file_path":"./test_builtin_json_custom_message.rb","line_number":14,"run_time":0.000193,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"\nexpected: {body: \"Not Found\", status: 404}\n got: {body: \"OK\", status: 200}\n\n(compared using ==)\n\nDiff:\n@@ -1,2 +1,2 @@\n-:body => \"Not Found\",\n-:status => 404,\n+:body => \"OK\",\n+:status => 200,\n","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_builtin_json_custom_message.rb:17:in 'block (2 levels) in '","------------------","--- Caused by: ---"," "," expected: {body: \"Not Found\", status: 404}"," got: {body: \"OK\", status: 200}"," "," (compared using ==)"," "," Diff:"," @@ -1,2 +1,2 @@"," -:body => \"Not Found\","," -:status => 404,"," +:body => \"OK\","," +:status => 200,"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}}],"summary":{"duration":0.005554,"example_count":3,"failure_count":3,"pending_count":0,"errors_outside_of_examples_count":0},"summary_line":"3 examples, 3 failures"} \ No newline at end of file diff --git a/lib/rspec/enriched_json/expectation_helper_wrapper.rb b/lib/rspec/enriched_json/expectation_helper_wrapper.rb index 74ba8b5..6a9768b 100644 --- a/lib/rspec/enriched_json/expectation_helper_wrapper.rb +++ b/lib/rspec/enriched_json/expectation_helper_wrapper.rb @@ -1,275 +1,201 @@ # frozen_string_literal: true require "json" +require "oj" require "rspec/expectations" require "rspec/support/differ" module RSpec module EnrichedJson - # Universal wrapper to catch ALL matchers and attach structured data + @all_test_values = {} + + def self.all_test_values + @all_test_values + end + + def self.clear_test_values + @all_test_values = {} + end + module ExpectationHelperWrapper - MAX_SERIALIZATION_DEPTH = 5 - MAX_ARRAY_SIZE = 100 - MAX_HASH_SIZE = 100 - MAX_STRING_LENGTH = 1000 def self.install! RSpec::Expectations::ExpectationHelper.singleton_class.prepend(self) + RSpec::Expectations::PositiveExpectationHandler.singleton_class.prepend(PositiveHandlerWrapper) + RSpec::Expectations::NegativeExpectationHandler.singleton_class.prepend(NegativeHandlerWrapper) end - # Make serialize_value accessible for other components module Serializer extend self - MAX_SERIALIZATION_DEPTH = 5 - MAX_ARRAY_SIZE = 100 - MAX_HASH_SIZE = 100 - MAX_STRING_LENGTH = 1000 - - def serialize_value(value, depth = 0) - return "[Max depth exceeded]" if depth > MAX_SERIALIZATION_DEPTH - - case value - when Numeric, TrueClass, FalseClass - value - when String - unescape_string_double_quotes( - truncate_string(value) - ) - when Symbol - serialize_object(value) - when nil - serialize_object(value) - when Array - return "[Large array: #{value.size} items]" if value.size > MAX_ARRAY_SIZE - value.map { |v| serialize_value(v, depth + 1) } - when Hash - return "[Large hash: #{value.size} keys]" if value.size > MAX_HASH_SIZE - value.transform_values { |v| serialize_value(v, depth + 1) } - else - serialize_object(value, depth) - end - rescue => e - { - "class" => value.class.name, - "serialization_error" => e.message - } - end - - def serialize_object(obj, depth = 0) - result = { - "class" => obj.class.name, - "inspect" => safe_inspect(obj), - "to_s" => safe_to_s(obj) - } - - # Handle Structs specially - if obj.is_a?(Struct) - result["struct_values"] = obj.to_h.transform_values { |v| serialize_value(v, depth + 1) } - end - - # Include instance variables only for small objects - ivars = obj.instance_variables - if ivars.any? && ivars.length <= 10 - result["instance_variables"] = ivars.each_with_object({}) do |ivar, hash| - hash[ivar.to_s] = serialize_value(obj.instance_variable_get(ivar), depth + 1) - end - end - - result - end - - def truncate_string(str) - return str if str.length <= MAX_STRING_LENGTH - "#{str[0...MAX_STRING_LENGTH]}... (truncated)" - end + OJ_OPTIONS = { + mode: :object, + circular: true, + class_cache: false, + create_additions: false, + symbol_keys: false, + auto_define: false, + create_id: nil, + use_to_json: false, + use_as_json: false, + use_raw_json: false, + bigdecimal_as_decimal: true, + nan: :word + } - def unescape_string_double_quotes(str) - if str.start_with?('"') && str.end_with?('"') - begin - # Only undump if it's a valid dumped string - # Check if the string is properly escaped by attempting undump - str.undump - rescue RuntimeError => e - # If undump fails, just return the original string - # This handles cases where the string has quotes but isn't a valid dump format - str - end - else - str + def serialize_value(value) + if value.is_a?(Regexp) + return Oj.dump(value.inspect, mode: :compat) end - end - def safe_inspect(obj) - truncate_string(obj.inspect) + Oj.dump(value, OJ_OPTIONS) rescue => e - "[inspect failed: #{e.class}]" - end - - def safe_to_s(obj) - truncate_string(obj.to_s) - rescue => e - "[to_s failed: #{e.class}]" + Oj.dump({ + "_serialization_error" => e.message, + "_class" => value.class.name, + "_to_s" => begin + value.to_s + rescue + "[to_s failed]" + end + }, mode: :compat) end end def handle_failure(matcher, message, failure_message_method) - # If a custom message is provided, capture the original message first original_message = nil if message original_message = matcher.send(failure_message_method) end - # Call original handler with the original message super rescue RSpec::Expectations::ExpectationNotMetError => e - # Extract raw values for diff analysis - expected_raw = extract_value(matcher, :expected) - actual_raw = extract_value(matcher, :actual) + expected_raw = extract_value(matcher, :expected, failure_message_method) + actual_raw = extract_value(matcher, :actual, failure_message_method) + negated = failure_message_method == :failure_message_when_negated - # Collect structured data details = { expected: Serializer.serialize_value(expected_raw), actual: Serializer.serialize_value(actual_raw), - original_message: original_message, # Only populated when custom message overrides it + original_message: original_message, matcher_name: matcher.class.name, - diffable: values_diffable?(expected_raw, actual_raw, matcher) + diffable: matcher.respond_to?(:diffable?) && matcher.diffable?, + negated: negated } - # Generate diff if values are diffable if details[:diffable] && expected_raw && actual_raw diff = generate_diff(actual_raw, expected_raw) details[:diff] = diff unless diff.nil? || diff.strip.empty? end - # Capture matcher-specific instance variables - matcher_data = extract_matcher_specific_data(matcher) - details.merge!(matcher_data) unless matcher_data.empty? - - # Raise new exception with data attached raise EnrichedExpectationNotMetError.new(e.message, details) end private - def extract_value(matcher, method_name) - return nil unless matcher.respond_to?(method_name) - - value = matcher.send(method_name) - # Don't return nil if the value itself is nil - # Only return nil if the value is the matcher itself (self-referential) - (value == matcher && !value.nil?) ? nil : value + def extract_value(matcher, method_name, failure_message_method = nil) + if matcher.is_a?(RSpec::Matchers::BuiltIn::BePredicate) || matcher.is_a?(RSpec::Matchers::BuiltIn::Has) + case method_name + when :expected + !(failure_message_method == :failure_message_when_negated) + when :actual + if matcher.instance_variable_defined?(:@predicate_result) + matcher.instance_variable_get(:@predicate_result) + end + end + else + return nil unless matcher.respond_to?(method_name) + value = matcher.send(method_name) + (value == matcher && !value.nil?) ? nil : value + end rescue nil end - def extract_matcher_specific_data(matcher) - # Skip common instance variables that are already captured - skip_vars = [ - :@expected, :@actual, :@args, :@name, - # Skip internal implementation details - :@matcher, :@matchers, :@target, - :@delegator, :@base_matcher, - :@block, :@event_proc, - # Skip verbose internal state - :@pairings_maximizer, :@best_solution, - :@expected_captures, :@match_captures, - :@failures, :@errors, - # Skip RSpec internals - :@matcher_execution_context, - :@chained_method_with_args_combos - ] + def generate_diff(actual, expected) + differ = RSpec::Support::Differ.new( + object_preparer: lambda { |obj| + RSpec::Matchers::Composable.surface_descriptions_in(obj) + }, + color: RSpec.configuration.color_enabled? + ) + differ.diff(actual, expected) + rescue + nil + end + end - # Define meaningful variables we want to keep - useful_vars = [ - :@missing_items, :@extra_items, # ContainExactly - :@expecteds, :@actuals, # Include - :@operator, :@delta, :@tolerance, # Comparison matchers - :@expected_before, :@expected_after, :@actual_before, :@actual_after, # Change matcher - :@from, :@to, :@minimum, :@maximum, :@count, # Various matchers - :@failure_message, :@failure_message_when_negated, - :@description - ] + module HandlerWrapperShared + def capture_test_values(actual, initial_matcher, negated: false) + return unless initial_matcher && RSpec.current_example - # Get all instance variables - ivars = matcher.instance_variables - skip_vars - return {} if ivars.empty? + begin + if initial_matcher.is_a?(RSpec::Matchers::BuiltIn::BePredicate) || initial_matcher.is_a?(RSpec::Matchers::BuiltIn::Has) + expected_value = !negated - # Build a hash of matcher-specific data - matcher_data = {} + if negated && initial_matcher.respond_to?(:does_not_match?) + initial_matcher.does_not_match?(actual) + else + initial_matcher.matches?(actual) + end - ivars.each do |ivar| - # Only include if it's in our useful list or looks like user data - unless useful_vars.include?(ivar) || ivar.to_s.match?(/^@(missing|extra|failed|unmatched|matched)_/) - next + actual_value = if initial_matcher.instance_variable_defined?(:@predicate_result) + initial_matcher.instance_variable_get(:@predicate_result) + end + else + expected_value = initial_matcher.respond_to?(:expected) ? initial_matcher.expected : nil + actual_value = actual end - value = matcher.instance_variable_get(ivar) - - # Skip if value is nil or the matcher itself - next if value.nil? || value == matcher + key = RSpec.current_example.id + RSpec::EnrichedJson.all_test_values[key] = { + expected: ExpectationHelperWrapper::Serializer.serialize_value(expected_value), + actual: ExpectationHelperWrapper::Serializer.serialize_value(actual_value), + matcher_name: initial_matcher.class.name, + passed: nil + } - # Skip procs and complex objects unless they're simple collections - if value.is_a?(Proc) || (value.is_a?(Object) && !value.is_a?(Enumerable) && !value.is_a?(Numeric) && !value.is_a?(String) && !value.is_a?(Symbol)) - next + RSpec::EnrichedJson.all_test_values[key][:negated] = true if negated + rescue => e + if defined?(RSpec.configuration) && RSpec.configuration.reporter + RSpec.configuration.reporter.message("Warning: Error capturing test values: #{e.message}") + elsif ENV["DEBUG"] + puts "Error capturing test values: #{e.message}" end - - # Convert instance variable name to a more readable format - # @missing_items -> missing_items - key = ivar.to_s.delete_prefix("@").to_sym - - # Serialize the value - matcher_data[key] = Serializer.serialize_value(value) - rescue - # Skip this instance variable if we can't serialize it - next end - - matcher_data end - def values_diffable?(expected, actual, matcher) - # First check if the matcher itself declares diffability - if matcher.respond_to?(:diffable?) - return matcher.diffable? - end + def mark_as_passed(initial_matcher) + return unless initial_matcher && RSpec.current_example - # If either value is nil, not diffable - return false if expected.nil? || actual.nil? + key = RSpec.current_example.id + if RSpec::EnrichedJson.all_test_values[key] + RSpec::EnrichedJson.all_test_values[key][:passed] = true + end + end + end - # For different classes, generally not diffable - return false unless actual.instance_of?(expected.class) + module PositiveHandlerWrapper + include HandlerWrapperShared - # Check if both values are of the same basic diffable type - case expected - when String, Array, Hash - # These types are inherently diffable when compared to same type - true - else - # For other types, they're diffable if they respond to to_s - # and their string representations would be meaningful - expected.respond_to?(:to_s) && actual.respond_to?(:to_s) - end - rescue - # If any error occurs during checking, assume not diffable - false + def handle_matcher(actual, initial_matcher, custom_message = nil, &block) + capture_test_values(actual, initial_matcher, negated: false) + result = super + mark_as_passed(initial_matcher) + result end + end - def generate_diff(actual, expected) - # Use RSpec's own differ for consistency - differ = RSpec::Support::Differ.new( - object_preparer: lambda { |obj| - RSpec::Matchers::Composable.surface_descriptions_in(obj) - }, - color: false # Always disable color for JSON output - ) - differ.diff(actual, expected) - rescue - # If diff generation fails, return nil rather than crashing - nil + module NegativeHandlerWrapper + include HandlerWrapperShared + + def handle_matcher(actual, initial_matcher, custom_message = nil, &block) + capture_test_values(actual, initial_matcher, negated: true) + result = super + mark_as_passed(initial_matcher) + result end end end end -# Auto-install when this file is required RSpec::EnrichedJson::ExpectationHelperWrapper.install! diff --git a/lib/rspec/enriched_json/formatters/enriched_json_formatter.rb b/lib/rspec/enriched_json/formatters/enriched_json_formatter.rb index ba8141a..c548d79 100644 --- a/lib/rspec/enriched_json/formatters/enriched_json_formatter.rb +++ b/lib/rspec/enriched_json/formatters/enriched_json_formatter.rb @@ -12,9 +12,7 @@ class EnrichedJsonFormatter < RSpec::Core::Formatters::JsonFormatter def stop(group_notification) @output_hash[:examples] = group_notification.notifications.map do |notification| format_example(notification.example).tap do |hash| - # Add enhanced metadata add_metadata(hash, notification.example) - e = notification.example.exception if e @@ -24,16 +22,14 @@ def stop(group_notification) backtrace: notification.formatted_backtrace } - # Add structured data if available if e.is_a?(RSpec::EnrichedJson::EnrichedExpectationNotMetError) && e.details - hash[:details] = safe_structured_data(e.details) + hash[:details] = e.details end - - if hash.key?(:details) && hash[:details].key?(:expected) && hash[:details].key?(:actual) - exception_message = hash[:exception][:message] - if exception_message.include?("\nDiff:") - hash[:exception][:message] = exception_message.sub(/Diff:.*/m, "").strip - end + else + key = notification.example.id + if RSpec::EnrichedJson.all_test_values.key?(key) + captured_values = RSpec::EnrichedJson.all_test_values[key] + hash[:details] = captured_values end end end @@ -45,38 +41,25 @@ def stop(group_notification) def add_metadata(hash, example) metadata = example.metadata.dup - # Extract custom tags (all symbols and specific keys) custom_tags = {} metadata.each do |key, value| - # Include all symbol keys (like :focus, :slow, etc.) if key.is_a?(Symbol) && value == true custom_tags[key] = true - # Include specific metadata that might be useful elsif [:type, :priority, :severity, :db, :js].include?(key) custom_tags[key] = value end end - # Add enhanced metadata hash[:metadata] = { - # Location information location: example.location, absolute_file_path: File.expand_path(example.metadata[:file_path]), rerun_file_path: example.location_rerun_argument, - - # Example hierarchy example_group: example.example_group.description, example_group_hierarchy: extract_group_hierarchy(example), - - # Described class if available described_class: metadata[:described_class]&.to_s, - - # Custom tags and metadata tags: custom_tags.empty? ? nil : custom_tags, - - # Shared example information if applicable shared_group_inclusion_backtrace: metadata[:shared_group_inclusion_backtrace] - }.compact # Remove nil values + }.compact end def extract_group_hierarchy(example) @@ -91,54 +74,9 @@ def extract_group_hierarchy(example) hierarchy end - def safe_structured_data(details) - # Start with core fields - result = { - expected: safe_serialize(details[:expected]), - actual: safe_serialize(details[:actual]), - matcher_name: details[:matcher_name], - original_message: details[:original_message], - diffable: details[:diffable] - } - - # Add any additional matcher-specific fields - details.each do |key, value| - next if [:expected, :actual, :matcher_name, :original_message, :diffable].include?(key) - result[key] = safe_serialize(value) - end - - result.compact - end - - def safe_serialize(value) - # Delegate to the existing serialization logic in ExpectationHelperWrapper - RSpec::EnrichedJson::ExpectationHelperWrapper::Serializer.serialize_value(value) - rescue => e - # Better error recovery - provide context about what failed - begin - obj_class = value.class.name - rescue - obj_class = "Unknown" - end - - { - "serialization_error" => true, - "error_class" => e.class.name, - "error_message" => e.message, - "object_class" => obj_class, - "fallback_value" => safe_fallback_value(value) - } - end - - def safe_fallback_value(value) - # Try multiple fallback strategies - value.to_s - rescue - begin - value.class.name - rescue - "Unable to serialize" - end + def close(_notification) + super + RSpec::EnrichedJson.clear_test_values end end end diff --git a/lib/rspec/enriched_json/version.rb b/lib/rspec/enriched_json/version.rb index 898f875..1f1549c 100644 --- a/lib/rspec/enriched_json/version.rb +++ b/lib/rspec/enriched_json/version.rb @@ -2,6 +2,6 @@ module RSpec module EnrichedJson - VERSION = "0.6.2" + VERSION = "0.8.0" end end diff --git a/rspec-enriched_json.gemspec b/rspec-enriched_json.gemspec index ee74855..f3790fb 100644 --- a/rspec-enriched_json.gemspec +++ b/rspec-enriched_json.gemspec @@ -20,9 +20,10 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/firstdraft/rspec-enriched_json" } - spec.required_ruby_version = ">= 2.7.0" + spec.required_ruby_version = ">= 3.2" spec.add_dependency "rspec-core", ">= 3.0" spec.add_dependency "rspec-expectations", ">= 3.0" + spec.add_dependency "oj", "~> 3.16" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rake", "~> 13.0" diff --git a/spec/diff_info_spec.rb b/spec/diff_info_spec.rb index 8476e38..267d08e 100644 --- a/spec/diff_info_spec.rb +++ b/spec/diff_info_spec.rb @@ -18,22 +18,6 @@ expect(output["examples"].first["details"]["diffable"]).to eq(true) end - it "captures the unescaped string actual output" do - test_content = <<~RUBY - RSpec.describe "String diff" do - it "compares strings" do - expect("\\"Hello, ---world\\"").to match("Hello, world") - end - end - RUBY - - output = run_formatter_with_content(test_content) - details = output["examples"].first["details"] - - expect(details["expected"]).to eq("Hello, world") - expect(details["actual"]).to eq("Hello, ---world") - end - it "marks array comparisons as diffable" do test_content = <<~RUBY RSpec.describe "Array diff" do @@ -77,30 +61,6 @@ expect(output["examples"].first["details"]["diffable"]).to eq(true) end - it "marks same class objects as diffable if they respond to to_s" do - test_content = <<~RUBY - class Person - attr_reader :name - def initialize(name) - @name = name - end - def to_s - @name - end - end - - RSpec.describe "Object diff" do - it "compares objects" do - expect(Person.new("Alice")).to eq(Person.new("Bob")) - end - end - RUBY - - output = run_formatter_with_content(test_content) - - expect(output["examples"].first["details"]["diffable"]).to eq(true) - end - it "marks nil comparisons as diffable when matcher says so" do test_content = <<~RUBY RSpec.describe "Nil comparison" do @@ -139,57 +99,5 @@ def diffable? expect(output["examples"].first["details"]["diffable"]).to eq(false) end - - it "uses our logic when matcher has no diffable? method" do - test_content = <<~RUBY - # Custom matcher without diffable? method - class SimpleMatcher - def matches?(actual) - @actual = actual - false - end - - def failure_message - "failed" - end - - def expected - nil - end - - def actual - @actual - end - end - - RSpec.describe "Matcher without diffable?" do - it "nil comparison uses our logic" do - expect("something").to SimpleMatcher.new - end - end - RUBY - - output = run_formatter_with_content(test_content) - - # Our logic: nil vs string is not diffable - expect(output["examples"].first["details"]["diffable"]).to eq(false) - end - end - - it "removes diff from the message if expected and actual are present" do - test_content = <<~RUBY - RSpec.describe "Account balance" do - it "matches a sub-string" do - expect("Your account balance is: -50").to match(/Your account balance is: [1-9]\d*/) - end - end - RUBY - - output = run_formatter_with_content(test_content) - message = output["examples"].first["exception"]["message"] - - expect(message).to include("expected \"Your account balance is: -50\"") - expect(message).to include("to match /Your account balance is:") - expect(message).not_to include("Diff:") end end diff --git a/spec/edge_cases_spec.rb b/spec/edge_cases_spec.rb deleted file mode 100644 index 31cf2a4..0000000 --- a/spec/edge_cases_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -RSpec.describe "Edge case handling" do - it "handles circular references gracefully" do - a = [] - a << a # Circular reference - - begin - expect(a).to eq([1, 2, 3]) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should not crash, should handle gracefully - expect(e.details[:actual]).to be_a(Array) - end - end - - it "handles matchers without expected/actual methods" do - # Some matchers might not have these methods - - expect { raise "error" }.not_to raise_error - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should handle gracefully with nil values - expect(e.details[:expected]["class"]).to eq("NilClass") - expect(e.details[:actual]["class"]).to eq("NilClass") - end - - it "handles encoding issues" do - invalid_utf8 = (+"\xFF\xFE").force_encoding("UTF-8") - - begin - expect(invalid_utf8).to eq("valid string") - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should not crash on invalid encoding - expect(e.details).to have_key(:actual) - end - end - - it "handles exceptions during serialization" do - class BadObject # rubocop:disable Lint/ConstantDefinitionInBlock - def inspect - raise "Cannot inspect!" - end - - def to_s - raise "Cannot convert to string!" - end - end - - begin - expect(BadObject.new).to eq("something") - rescue => e # Catch any exception, not just our enriched one - # The error happens during RSpec's message generation, before our code runs - # This is actually a limitation - if inspect fails, RSpec itself will fail - expect(e.class).to eq(RuntimeError) - expect(e.message).to eq("Cannot inspect!") - end - end - - context "handling Ruby literals" do - it "serializes nil" do - expect("Alice").to eq(nil) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should not crash on invalid encoding - expect(e.details[:expected]["class"]).to eq("NilClass") - expect(e.details[:expected]["inspect"]).to eq("nil") - expect(e.details[:expected]["to_s"]).to eq("") - end - - it "serializes symbols" do - expect(:name).to eq(:age) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should not crash on invalid encoding - expect(e.details[:expected]["class"]).to eq("Symbol") - expect(e.details[:expected]["inspect"]).to eq(":age") - expect(e.details[:expected]["to_s"]).to eq("age") - end - end - - context "handling strings with newlines" do - it "handles strings with embedded newlines and quotes" do - # This was causing "invalid dumped string" errors before the fix - output = "\"l appears 1 times\"\n\"o appears 2 times\"\n\"o appears 2 times\"\n\"p appears 1 times\"" - expected = "\"l appears 1 times\"\n\"o appears 2 times\"\n\"e appears 1 times\"\n\"p appears 1 times\"" - - expect(output).to eq(expected) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Verify the strings are properly serialized without undump errors - expect(e.details[:actual]).to be_a(String) - expect(e.details[:expected]).to be_a(String) - - # The actual should contain the newlines (actual newlines, not escaped) - expect(e.details[:actual]).to include("\n") - expect(e.details[:actual]).to include("l appears 1 times") - expect(e.details[:actual]).to include("o appears 2 times") - - # The expected should also be properly serialized - expect(e.details[:expected]).to include("\n") - expect(e.details[:expected]).to include("e appears 1 times") - - # Should not have serialization errors - expect(e.details[:actual]).not_to be_a(Hash) - expect(e.details[:actual]).not_to include("serialization_error") - end - end -end diff --git a/spec/integration/enriched_json_formatter_integration_spec.rb b/spec/integration/enriched_json_formatter_integration_spec.rb index c1d8a7a..e4f8f1d 100644 --- a/spec/integration/enriched_json_formatter_integration_spec.rb +++ b/spec/integration/enriched_json_formatter_integration_spec.rb @@ -35,8 +35,8 @@ def run_rspec_with_enriched_formatter(spec_content) example = result["examples"].first expect(example["status"]).to eq("failed") expect(example["details"]).to include( - "expected" => 3, - "actual" => 2, + "expected" => "3", + "actual" => "2", "matcher_name" => "RSpec::Matchers::BuiltIn::Eq" ) end @@ -61,7 +61,7 @@ def run_rspec_with_enriched_formatter(spec_content) expect(example["details"]["original_message"]).to include("expected: >= 100") end - it "does not add structured data for passing tests" do + it "adds structured data for passing tests" do spec_content = <<~RUBY require 'rspec/enriched_json' @@ -76,7 +76,9 @@ def run_rspec_with_enriched_formatter(spec_content) example = result["examples"].first expect(example["status"]).to eq("passed") - expect(example).not_to have_key("details") + # We now capture values for passing tests too + expect(example).to have_key("details") + expect(example["details"]["passed"]).to eq(true) end it "handles regular exceptions without structured data" do diff --git a/spec/memory_cleanup_spec.rb b/spec/memory_cleanup_spec.rb new file mode 100644 index 0000000..09f0102 --- /dev/null +++ b/spec/memory_cleanup_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rspec" +require "rspec/enriched_json" + +RSpec.describe "Memory cleanup" do + it "formatter cleans up test values after outputting JSON" do + # The cleanup now happens in the formatter's close method + # to ensure values are available when formatting + + # Store some test values + RSpec::EnrichedJson.all_test_values["test1"] = {expected: 1, actual: 2} + RSpec::EnrichedJson.all_test_values["test2"] = {expected: "a", actual: "b"} + + expect(RSpec::EnrichedJson.all_test_values).not_to be_empty + + # The formatter will call clear_test_values in its close method + # For now, we just verify the method exists and works + expect(RSpec::EnrichedJson).to respond_to(:clear_test_values) + end + + it "can clear test values" do + # Simply verify the method exists and works + RSpec::EnrichedJson.all_test_values["dummy_test"] = {expected: 1, actual: 2} + + RSpec::EnrichedJson.clear_test_values + + # Check that dummy_test was removed (ignore any values from this test itself) + expect(RSpec::EnrichedJson.all_test_values["dummy_test"]).to be_nil + end +end diff --git a/spec/negated_matcher_spec.rb b/spec/negated_matcher_spec.rb new file mode 100644 index 0000000..cfca572 --- /dev/null +++ b/spec/negated_matcher_spec.rb @@ -0,0 +1,89 @@ +require "spec_helper" +require "json" + +RSpec.describe "Negated matcher handling" do + let(:output) { StringIO.new } + let(:formatter) { RSpec::EnrichedJson::Formatters::EnrichedJsonFormatter.new(output) } + + context "integration with formatter" do + it "includes negated flag in JSON output" do + # Create a separate configuration to avoid affecting global state + config = RSpec::Core::Configuration.new + config.add_formatter(formatter) + + reporter = RSpec::Core::Reporter.new(config) + reporter.register_listener(formatter, :message, :dump_summary, :dump_profile, :stop, :seed, :close) + + example_group = RSpec.describe "Negated tests" do + it "negated failure" do + expect(true).not_to be true + end + + it "regular failure" do + expect(false).to be true + end + end + + reporter.start(2) + example_group.run(reporter) + reporter.finish + + output.rewind + result = JSON.parse(output.read) + + negated_example = result["examples"].find { |ex| ex["description"] == "negated failure" } + regular_example = result["examples"].find { |ex| ex["description"] == "regular failure" } + + expect(negated_example["details"]["negated"]).to eq(true) + expect(regular_example["details"]["negated"]).to eq(false) + end + end + + context "with passing tests that have negated expectations" do + it "captures negated flag for passing not_to tests" do + RSpec::EnrichedJson.clear_test_values + + example_group = RSpec.describe do + it "passes with not_to" do + expect(5).not_to eq(6) + end + end + + example = example_group.examples.first + example_group.run(RSpec.configuration.reporter) + + key = example.id + captured_values = RSpec::EnrichedJson.all_test_values[key] + + expect(captured_values).to include( + negated: true, + passed: true, + expected: "6", + actual: "5" + ) + end + + it "captures negated flag for passing regular tests" do + RSpec::EnrichedJson.clear_test_values + + example_group = RSpec.describe do + it "passes with regular to" do + expect(5).to eq(5) + end + end + + example = example_group.examples.first + example_group.run(RSpec.configuration.reporter) + + key = example.id + captured_values = RSpec::EnrichedJson.all_test_values[key] + + expect(captured_values).not_to include(:negated) # Should not have negated key for positive expectations + expect(captured_values).to include( + passed: true, + expected: "5", + actual: "5" + ) + end + end +end diff --git a/spec/oj_serialization_spec.rb b/spec/oj_serialization_spec.rb new file mode 100644 index 0000000..0fe9030 --- /dev/null +++ b/spec/oj_serialization_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Special serialization cases" do + let(:serializer) { RSpec::EnrichedJson::ExpectationHelperWrapper::Serializer } + + describe "Regexp serialization (our special case)" do + it "serializes simple regex as inspect string" do + result = JSON.parse(serializer.serialize_value(/hello/)) + expect(result).to eq("/hello/") + end + + it "serializes regex with case-insensitive flag" do + result = JSON.parse(serializer.serialize_value(/hello/i)) + expect(result).to eq("/hello/i") + end + + it "serializes regex with multiline flag" do + result = JSON.parse(serializer.serialize_value(/hello/m)) + expect(result).to eq("/hello/m") + end + + it "serializes regex with extended flag" do + result = JSON.parse(serializer.serialize_value(/hello/x)) + expect(result).to eq("/hello/x") + end + + it "serializes regex with multiple flags in alphabetical order" do + result = JSON.parse(serializer.serialize_value(/hello/imx)) + # Ruby's inspect method shows flags in a specific order: mix + expect(result).to eq("/hello/mix") + end + + it "serializes regex with special characters" do + result = JSON.parse(serializer.serialize_value(/[a-z]+\s*\d{2,}/)) + expect(result).to eq("/[a-z]+\\s*\\d{2,}/") + end + + it "serializes regex with escaped forward slashes" do + result = JSON.parse(serializer.serialize_value(/http:\/\/example\.com/)) + expect(result).to eq("/http:\\/\\/example\\.com/") + end + end + + describe "Fallback behavior for errors" do + it "uses fallback format when Oj.dump fails" do + # Create an object that we'll mock to fail + obj = Object.new + + # Mock Oj.dump to fail for this specific object + allow(Oj).to receive(:dump).and_call_original + allow(Oj).to receive(:dump).with(obj, anything).and_raise("Serialization failed") + + result = JSON.parse(serializer.serialize_value(obj)) + expect(result).to be_a(Hash) + expect(result["_serialization_error"]).to eq("Serialization failed") + expect(result["_class"]).to match(/Object/) + expect(result["_to_s"]).to match(/# e - actual_data = e.details[:actual] - expect(actual_data.length).to be <= 1100 # 1000 + "... (truncated)" - expect(actual_data).to end_with("... (truncated)") - end - end - - it "handles large arrays" do - large_array = (1..200).to_a - - begin - expect(large_array).to eq([1, 2, 3]) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - expect(e.details[:actual]).to eq("[Large array: 200 items]") - end - end - - it "handles deeply nested structures" do - # Create a deeply nested structure - deeply_nested = {a: {b: {c: {d: {e: {f: {g: "bottom"}}}}}}} - - begin - expect(deeply_nested).to eq({}) - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - # Should serialize up to MAX_SERIALIZATION_DEPTH - actual = e.details[:actual] - expect(actual).to be_a(Hash) - - # Navigate to the depth limit - current = actual - 5.times do |i| - break unless current.is_a?(Hash) && current.values.first.is_a?(Hash) - current = current.values.first - end - - # At max depth, should see the truncation message - expect(current.values.first).to eq("[Max depth exceeded]") - end - end - - it "handles objects with many instance variables" do - class ManyVarsObject # rubocop:disable Lint/ConstantDefinitionInBlock - def initialize - 15.times { |i| instance_variable_set("@var#{i}", i) } - end - end - - obj = ManyVarsObject.new - - begin - expect(obj).to eq("something else") - rescue RSpec::EnrichedJson::EnrichedExpectationNotMetError => e - actual_data = e.details[:actual] - # Instance variables are only included if <= 10 - # Since we have 15, they should not be included at all - expect(actual_data["instance_variables"]).to be_nil - end - end -end diff --git a/spec/support/.gitkeep b/spec/support/.gitkeep new file mode 100644 index 0000000..0ca2fca --- /dev/null +++ b/spec/support/.gitkeep @@ -0,0 +1 @@ +# This directory is used for temporary test files during test execution \ No newline at end of file diff --git a/spec/verify_custom_message_spec.rb b/spec/verify_custom_message_spec.rb index a6697e9..b16d3a4 100644 --- a/spec/verify_custom_message_spec.rb +++ b/spec/verify_custom_message_spec.rb @@ -37,25 +37,4 @@ expect(e.details[:original_message]).to be_nil end end - - it "removes diff from the custom message if expected and actual are present" do - test_content = <<~RUBY - RSpec.describe "Account balance" do - it "matches a sub-string" do - expect("Your account balance is: -50").to match(/Your account balance is: [1-9]\d*/), "Insufficient funds" - end - end - RUBY - - output = run_formatter_with_content(test_content) - message = output["examples"].first["exception"]["message"] - original_message = output["examples"].first["details"]["original_message"] - - expect(message).to eq("Insufficient funds") - expect(message).not_to include("Diff:") - - expect(original_message).to include("expected \"Your account balance is: -50\"") - expect(original_message).to include("to match /Your account balance is:") - expect(original_message).not_to include("Diff:") - end end diff --git a/test_builtin_json_custom_message.rb b/test_builtin_json_custom_message.rb deleted file mode 100644 index 8217228..0000000 --- a/test_builtin_json_custom_message.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "rspec" - -RSpec.describe "Built-in JSON Formatter Custom Message Test" do - it "fails with a simple custom message" do - balance = 50 - required = 100 - expect(balance).to be >= required, "Insufficient funds: need $#{required} but only have $#{balance}" - end - - it "fails without custom message" do - expect(1 + 1).to eq(3) - end - - it "fails with hash expectation and custom message" do - actual_response = {status: 200, body: "OK"} - expected_response = {status: 404, body: "Not Found"} - expect(actual_response).to eq(expected_response), "API returned wrong response" - end -end diff --git a/test_vanilla_json.rb b/test_vanilla_json.rb deleted file mode 100644 index 5fc9cd2..0000000 --- a/test_vanilla_json.rb +++ /dev/null @@ -1,20 +0,0 @@ -# Don't require enriched_json to see vanilla RSpec behavior -require "rspec" - -RSpec.describe "Vanilla JSON Formatter Test" do - it "fails with a simple custom message" do - balance = 50 - required = 100 - expect(balance).to be >= required, "Insufficient funds: need $#{required} but only have $#{balance}" - end - - it "fails without custom message" do - expect(1 + 1).to eq(3) - end - - it "fails with hash expectation and custom message" do - actual_response = {status: 200, body: "OK"} - expected_response = {status: 404, body: "Not Found"} - expect(actual_response).to eq(expected_response), "API returned wrong response" - end -end diff --git a/vanilla_output.json b/vanilla_output.json deleted file mode 100644 index 407bcb0..0000000 --- a/vanilla_output.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"3.13.5","seed":31610,"examples":[{"id":"./test_vanilla_json.rb[1:1]","description":"fails with a simple custom message","full_description":"Vanilla JSON Formatter Test fails with a simple custom message","status":"failed","file_path":"./test_vanilla_json.rb","line_number":5,"run_time":0.006315,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"expected: >= 100\n got: 50","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_vanilla_json.rb:8:in 'block (2 levels) in '","------------------","--- Caused by: ---"," expected: >= 100"," got: 50"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}},{"id":"./test_vanilla_json.rb[1:2]","description":"fails without custom message","full_description":"Vanilla JSON Formatter Test fails without custom message","status":"failed","file_path":"./test_vanilla_json.rb","line_number":11,"run_time":0.000348,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"\nexpected: 3\n got: 2\n\n(compared using ==)\n","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_vanilla_json.rb:12:in 'block (2 levels) in '","------------------","--- Caused by: ---"," "," expected: 3"," got: 2"," "," (compared using ==)"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}},{"id":"./test_vanilla_json.rb[1:3]","description":"fails with hash expectation and custom message","full_description":"Vanilla JSON Formatter Test fails with hash expectation and custom message","status":"failed","file_path":"./test_vanilla_json.rb","line_number":15,"run_time":0.000208,"pending_message":null,"exception":{"class":"RSpec::EnrichedJson::EnrichedExpectationNotMetError","message":"\nexpected: {body: \"Not Found\", status: 404}\n got: {body: \"OK\", status: 200}\n\n(compared using ==)\n\nDiff:\n@@ -1,2 +1,2 @@\n-:body => \"Not Found\",\n-:status => 404,\n+:body => \"OK\",\n+:status => 200,\n","backtrace":["./lib/rspec/enriched_json/expectation_helper_wrapper.rb:27:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'","./test_vanilla_json.rb:18:in 'block (2 levels) in '","------------------","--- Caused by: ---"," "," expected: {body: \"Not Found\", status: 404}"," got: {body: \"OK\", status: 200}"," "," (compared using ==)"," "," Diff:"," @@ -1,2 +1,2 @@"," -:body => \"Not Found\","," -:status => 404,"," +:body => \"OK\","," +:status => 200,"," ./lib/rspec/enriched_json/expectation_helper_wrapper.rb:16:in 'RSpec::EnrichedJson::ExpectationHelperWrapper#handle_failure'"]}}],"summary":{"duration":0.007496,"example_count":3,"failure_count":3,"pending_count":0,"errors_outside_of_examples_count":0},"summary_line":"3 examples, 3 failures"} \ No newline at end of file