diff --git a/CHANGELOG.md b/CHANGELOG.md index aff3fe5..1e2423b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] - Drop `:process` and `:thread` workers since there are no concrete use cases. +- Drop `:experimental_ractor_rspec_integration` option since there are no concrete use cases. ## [0.4.2] - 2024-05-23 diff --git a/Gemfile b/Gemfile index caae970..a4abdbe 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,4 @@ gemspec gem "rake" gem "rspec" gem "standard" -gem "prism" gem "benchmark-ips" diff --git a/README.md b/README.md index fff5882..169e0ed 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,6 @@ Pbt.configure do |config| # Whether to report exceptions in threads. # It's useful to suppress error logs on Ractor that reports many errors. Default is `false`. config.thread_report_on_exception = false - - # Whether to allow RSpec expectation and matchers in Ractor. It's quite experimental! Default is `false`. - config.experimental_ractor_rspec_integration = false end ``` @@ -295,22 +292,6 @@ it do end ``` -If you're a challenger, you can enable the experimental feature to allow using RSpec expectations and matchers in Ractor. It works but it's quite experimental and could cause unexpected behaviors. - -Please note that this feature depends on [prism](https://ruby.github.io/prism/) gem. If you use Ruby 3.2 or prior, you need to install the gem by yourself. - -```ruby -it do - Pbt.assert(worker: :ractor, experimental_ractor_rspec_integration: true) do - Pbt.property(Pbt.integer) do |n| - # Some RSpec expectations and matchers are available in Ractor by hack. - # Other features like `let`, `subject`, `before`, `after` that access out of block are still not available. - expect(n).to be_an(Integer) - end - end -end -``` - ### None For most cases, `:none` is the best choice. It runs tests sequentially but most test cases finishes within a reasonable time. @@ -341,7 +322,7 @@ Once this project finishes the following, we will release v1.0.0. - [x] Configuration - [x] Benchmark - [x] Rich report by verbose mode -- [x] (Partially) Allow to use expectations and matchers provided by test framework in Ractor if possible. +- [x] (Partially) Allow to use expectations and matchers provided by test framework in Ractor. (dropped) - It'd be so hard to pass assertions like `expect`, `assert` to a Ractor. - [ ] Implement frequency arbitrary - [ ] Statistics feature to aggregate generated values diff --git a/lib/pbt/check/configuration.rb b/lib/pbt/check/configuration.rb index 3105f94..2446a5b 100644 --- a/lib/pbt/check/configuration.rb +++ b/lib/pbt/check/configuration.rb @@ -9,7 +9,6 @@ module Check :num_runs, :seed, :thread_report_on_exception, - :experimental_ractor_rspec_integration, keyword_init: true ) do # @param verbose [Boolean] Whether to print verbose output. Default is `false`. @@ -17,14 +16,12 @@ module Check # @param num_runs [Integer] The number of runs to perform. Default is `100`. # @param seed [Integer] The seed to use for random number generation. It's useful to reproduce failed test with the seed you'd pick up from failure messages. Default is a random seed. # @param thread_report_on_exception [Boolean] Whether to report exceptions in threads. It's useful to suppress error logs on Ractor that reports many errors. Default is `false`. - # @param experimental_ractor_rspec_integration [Boolean] Whether to allow RSpec expectation and matchers in Ractor. It's quite experimental! Default is `false`. def initialize( verbose: false, worker: :none, num_runs: 100, seed: Random.new_seed, - thread_report_on_exception: false, - experimental_ractor_rspec_integration: false + thread_report_on_exception: false ) super end diff --git a/lib/pbt/check/rspec_adapter/integration.rb b/lib/pbt/check/rspec_adapter/integration.rb deleted file mode 100644 index 32492f8..0000000 --- a/lib/pbt/check/rspec_adapter/integration.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -unless defined?(RSpec) - raise InvalidConfigurationError, "You configured `experimental_ractor_rspec_integration: true` but RSpec is not loaded. Please use RSpec or set the config `false`." -end - -require "pbt/check/rspec_adapter/predicate_block_inspector" -require "pbt/check/rspec_adapter/property_extension" - -module Pbt - module Check - # @private - module RSpecAdapter - # This custom error contains RSpec matcher and message to handle Pbt's runner. - # @private - class ExpectationNotMet < StandardError - attr_reader :matcher, :custom_message, :failure_message_method - - def initialize(msg, matcher, custom_message, failure_message_method) - super(msg) - @matcher = matcher - @custom_message = custom_message - @failure_message_method = failure_message_method - end - end - end - end -end - -# `autoload` is not allowed in Ractor but RSpec uses autoload for matchers. -# We need to load them in advance in order to be able to use them in Ractor. -# -# e.g. Ractor raises... `be_a_kind_of': require by autoload on non-main Ractor is not supported (BeAKindOf) (Ractor::UnsafeError) -RSpec::Matchers::BuiltIn.constants.each { |c| Object.const_get("RSpec::Matchers::BuiltIn::#{c}") } - -# TODO: preload more helpers like aggregate_failures. -# RSpec::Expectations.constants.each { |c| Object.const_get("RSpec::Expectations::#{c}") } -# The code above is not enough. Even if we run this code in advance, Ractor raises... -# in `failure_notifier': can not access non-shareable objects in constant RSpec::Support::DEFAULT_FAILURE_NOTIFIER by non-main ractor. (Ractor::IsolationError) - -# CAUTION: This is a dirty hack! We need to override the original method to make it Ractor-safe. -RSpec::Expectations::ExpectationHelper.singleton_class.prepend(Module.new do - def with_matcher(handler, matcher, message) - check_message(message) - matcher = modern_matcher_from(matcher) - yield matcher - ensure - # The original method is not Ractor-safe unless stopping assigning these class variables. - if Ractor.current == Ractor.main - ::RSpec::Matchers.last_expectation_handler = handler - ::RSpec::Matchers.last_matcher = matcher - end - end - - def handle_failure(matcher, message, failure_message_method) - # This method is not Ractor-safe. RSpec::Support::ObjectFormatter.default_instance assigns class variables. - # If this method is called in non-main-Ractor, it raises a custom error and let it handle in the main Ractor. - if Ractor.current != Ractor.main - raise Pbt::Check::RSpecAdapter::ExpectationNotMet.new(nil, matcher, message, failure_message_method) - end - - super - end -end) diff --git a/lib/pbt/check/rspec_adapter/predicate_block_inspector.rb b/lib/pbt/check/rspec_adapter/predicate_block_inspector.rb deleted file mode 100644 index 6d211e5..0000000 --- a/lib/pbt/check/rspec_adapter/predicate_block_inspector.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -begin - # Use prism to get user-defined block code. - require "prism" -rescue LoadError - raise InvalidConfiguration, - "Prism gem (https://github.com/ruby/prism) is required to use worker `:ractor` and `:experimental_ractor_rspec_integration`. Please add `gem 'prism'` to your Gemfile." -end - -module Pbt - module Check - module RSpecAdapter - # This class is used to get user-defined block code. - # If a user defines code like below: - # - # Pbt.property(Pbt.integer, Pbt.integer) do |x, y| - # x > 0 && y > 0 - # end - # - # inspector.method_params #=> "x, y" - # inspector.method_body #=> "x > 0 && y > 0" - # - # @private - # @!attribute [r] method_body - # @!attribute [r] method_params - class PredicateBlockInspector < Prism::Visitor - attr_reader :method_body, :method_params - - def initialize(line) - @line = line - @method_body = nil - super() - end - - def visit_call_node(node) - if node.name == :property && node.block.opening_loc.start_line == @line - @method_params = node.block.parameters.parameters.slice - @method_body = node.block.body.slice - end - - super - end - end - end - end -end diff --git a/lib/pbt/check/rspec_adapter/property_extension.rb b/lib/pbt/check/rspec_adapter/property_extension.rb deleted file mode 100644 index 1141f06..0000000 --- a/lib/pbt/check/rspec_adapter/property_extension.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module Pbt - module Check - module RSpecAdapter - # @private - module PropertyExtension - # Define an original class to be called in Ractor. - # - # @return [void] - def setup_rspec_integration - filepath, line = @predicate.source_location - basename = File.basename(filepath, ".rb") - @class_name = "Test" + basename.split("_").map(&:capitalize).join + line.to_s - @method_name = "predicate_#{basename}_#{line}" - define_ractor_callable_class - end - - # Clean up an original class to be called in Ractor to avoid any persisted namespace pollution. - # - # @return [void] - def teardown_rspec_integration - RSpecAdapter.__send__(:remove_const, @class_name) if RSpecAdapter.const_defined?(@class_name) - end - - # Run the predicate with the generated `val`. - # This overrides the original `Property#run_in_ractor`. - # - # @param val [Object] - # @return [Ractor] - def run_in_ractor(val) - Ractor.new(@class_name, @method_name, @predicate.parameters.size, val) do |class_name, method_name, param_size, val| - klass = RSpecAdapter.const_get(class_name) - if val.is_a?(Hash) - klass.new.send(method_name, **val) - elsif param_size >= 2 - klass.new.send(method_name, *val) - else - klass.new.send(method_name, val) - end - end - end - - private - - # @return [void] - def define_ractor_callable_class - # The @method_name is invisible in the Class.new block, so we need to assign it to a local variable. - method_name = @method_name - - inspector = extract_predicate_source_code - - RSpecAdapter.const_set(@class_name, Class.new do - include ::RSpec::Matchers - class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def #{method_name}(#{inspector.method_params}) - #{inspector.method_body} - end - RUBY - end) - end - - # @return [PredicateBlockInspector] - def extract_predicate_source_code - filepath, line = @predicate.source_location - PredicateBlockInspector.new(line).tap do |inspector| - res = Prism.parse_file(filepath) - res.value.statements.accept(inspector) - end - end - end - end - end -end diff --git a/lib/pbt/check/runner_methods.rb b/lib/pbt/check/runner_methods.rb index 769720e..da43a27 100644 --- a/lib/pbt/check/runner_methods.rb +++ b/lib/pbt/check/runner_methods.rb @@ -54,8 +54,6 @@ def check(**options, &property) # # - `:thread_report_on_exception` # So many exception reports happen in Ractor and a console gets too messy. Suppress them to avoid that. - # - `:experimental_ractor_rspec_integration` - # Allow to use Ractor with RSpec. This is an experimental feature and it's not stable. # # @param config [Hash] Configuration parameters. # @param property [Property] @@ -64,21 +62,12 @@ def setup_for_ractor(config, property, &block) if config[:worker] == :ractor original_report_on_exception = Thread.report_on_exception Thread.report_on_exception = config[:thread_report_on_exception] - - if config[:experimental_ractor_rspec_integration] - require "pbt/check/rspec_adapter/integration" - class << property - include Pbt::Check::RSpecAdapter::PropertyExtension - end - property.setup_rspec_integration - end end yield ensure if config[:worker] == :ractor Thread.report_on_exception = original_report_on_exception - property.teardown_rspec_integration if config[:experimental_ractor_rspec_integration] end end @@ -129,26 +118,11 @@ def run_it_in_ractors(property, runner) c.ractor.take runner.handle_result(c) rescue => e - handle_ractor_error(e.cause, c) + c.exception = e.cause # Ractor error is wrapped in a Ractor::RemoteError. We need to get the cause. runner.handle_result(c) break # Ignore the rest of the cases. Just pick up the first failure. end end - - def handle_ractor_error(cause, c) - # Ractor error is wrapped in a Ractor::RemoteError. We need to get the cause. - unless defined?(Pbt::Check::RSpecAdapter) && cause.is_a?(Pbt::Check::RSpecAdapter::ExpectationNotMet) # Unknown error. - c.exception = cause - return - end - - # Convert Pbt's custom error to RSpec's error. - begin - RSpec::Expectations::ExpectationHelper.handle_failure(cause.matcher, cause.custom_message, cause.failure_message_method) - rescue RSpec::Expectations::ExpectationNotMetError => e # The class inherits Exception, not StandardError. - c.exception = e - end - end end end end diff --git a/spec/pbt/check/configuration_spec.rb b/spec/pbt/check/configuration_spec.rb index f2942f7..134e373 100644 --- a/spec/pbt/check/configuration_spec.rb +++ b/spec/pbt/check/configuration_spec.rb @@ -64,8 +64,7 @@ worker: :ractor, num_runs: 5, seed: anything, - thread_report_on_exception: false, - experimental_ractor_rspec_integration: false + thread_report_on_exception: false } ) end @@ -99,8 +98,7 @@ worker: :ractor, num_runs: 10, seed:, - thread_report_on_exception: false, - experimental_ractor_rspec_integration: false + thread_report_on_exception: false } ) end @@ -130,8 +128,7 @@ worker: :none, num_runs: 5, seed: anything, - thread_report_on_exception: false, - experimental_ractor_rspec_integration: false + thread_report_on_exception: false } ) end @@ -165,8 +162,7 @@ worker: :none, num_runs: 10, seed:, - thread_report_on_exception: false, - experimental_ractor_rspec_integration: false + thread_report_on_exception: false } ) end @@ -257,53 +253,5 @@ end end end - - describe "experimental_ractor_rspec_integration" do - it "allows to use RSpec expectation and matchers" do - Pbt.assert num_runs: 5, worker: :ractor, experimental_ractor_rspec_integration: true do - Pbt.property(Pbt.integer) do |x| - expect(x).to be_a(Integer) - end - end - - Pbt.assert num_runs: 5, worker: :ractor, experimental_ractor_rspec_integration: true do - Pbt.property(Pbt.integer, Pbt.integer) do |x, y| - expect(x + y).to be_a(Integer) - end - end - - Pbt.assert num_runs: 5, worker: :ractor, experimental_ractor_rspec_integration: true do - Pbt.property(Pbt.array(Pbt.integer, empty: false)) do |nums| - expect(nums).to be_a(Array) - expect(nums[0]).to be_a(Integer) - end - end - - Pbt.assert num_runs: 5, worker: :ractor, experimental_ractor_rspec_integration: true do - Pbt.property(x: Pbt.integer, y: Pbt.integer) do |x:, y:| - expect(x + y).to be_a(Integer) - end - end - end - - it "raises Pbt::PropertyFailure that wraps RSpec's exception when expectation failed" do - expect { - seed = 135479457171118952930684770951487304295 - Pbt.assert num_runs: 5, worker: :ractor, seed:, experimental_ractor_rspec_integration: true do - Pbt.property(Pbt.integer) do |i| - expect(i).to be_a(String) - end - end - }.to raise_error(Pbt::PropertyFailure) do |e| - expect(e.message).to include <<~MSG.chomp - Property failed after 1 test(s) - seed: 135479457171118952930684770951487304295 - counterexample: 0 - Shrunk 21 time(s) - Got RSpec::Expectations::ExpectationNotMetError: expected 0 to be a kind of String - MSG - end - end - end end end