diff --git a/.gitignore b/.gitignore index 2d9a1eaf52..d13d81b13b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ Gemfile-custom .idea bundle .rspec-local +spec/examples.txt diff --git a/.rubocop.yml b/.rubocop.yml index 368bdfb4d4..449b65d10e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -25,7 +25,7 @@ Lint/LiteralInInterpolation: # This should go down over time. MethodLength: - Max: 155 + Max: 40 # Exclude the default spec_helper to make it easier to uncomment out # default settings (for both users and the Cucumber suite). diff --git a/.rubocop_rspec_base.yml b/.rubocop_rspec_base.yml index f7bea1c203..c80142af50 100644 --- a/.rubocop_rspec_base.yml +++ b/.rubocop_rspec_base.yml @@ -1,4 +1,4 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. # This file contains defaults for RSpec projects. Individual projects diff --git a/.travis.yml b/.travis.yml index ea4f1b0fcc..1e841a7805 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. language: ruby @@ -22,16 +22,24 @@ rvm: - 2.2 - ruby-head - ree - - jruby-18mode - - jruby - - jruby-head - rbx matrix: include: - rvm: jruby - env: JRUBY_OPTS='--2.0' + env: JRUBY_OPTS='--server -Xcompile.invokedynamic=false -Xcompat.version=2.0' + - rvm: jruby-head + env: JRUBY_OPTS='--server -Xcompile.invokedynamic=false' + - rvm: jruby-18mode + env: JRUBY_OPTS='--server -Xcompile.invokedynamic=false' + - rvm: jruby + env: JRUBY_OPTS='--server -Xcompile.invokedynamic=false' allow_failures: - rvm: jruby-head + env: JRUBY_OPTS='--server -Xcompile.invokedynamic=false' - rvm: ruby-head - rvm: rbx fast_finish: true +branches: + only: + - master + - /^\d+-\d+-maintenance$/ diff --git a/Changelog.md b/Changelog.md index 68be1747e3..a0f9aa7bb6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,108 @@ +### 3.3.0 / 2015-06-12 +[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.2.3...v3.3.0) + +Enhancements: + +* Expose the reporter used to run examples via `RSpec::Core::Example#reporter`. + (Jon Rowe, #1866) +* Make `RSpec::Core::Reporter#message` a public supported API. (Jon Rowe, #1866) +* Allow custom formatter events to be published via + `RSpec::Core::Reporter#publish(event_name, hash_of_attributes)`. (Jon Rowe, #1869) +* Remove dependency on the standard library `Set` and replace with `RSpec::Core::Set`. + (Jon Rowe, #1870) +* Assign a unique id to each example and group so that they can be + uniquely identified, even for shared examples (and similar situations) + where the location isn't unique. (Myron Marston, #1884) +* Use the example id in the rerun command printed for failed examples + when the location is not unique. (Myron Marston, #1884) +* Add `config.example_status_persistence_file_path` option, which is + used to persist the last run status of each example. (Myron Marston, #1888) +* Add `:last_run_status` metadata to each example, which indicates what + happened the last time an example ran. (Myron Marston, #1888) +* Add `--only-failures` CLI option which filters to only the examples + that failed the last time they ran. (Myron Marston, #1888) +* Add `--next-failure` CLI option which allows you to repeatedly focus + on just one of the currently failing examples, then move on to the + next failure, etc. (Myron Marston, #1888) +* Make `--order random` ordering stable, so that when you rerun a + subset with a given seed, the examples will be order consistently + relative to each other. (Myron Marston, #1908) +* Set example group constant earlier so errors when evaluating the context + include the example group name (Myron Marson, #1911) +* Make `let` and `subject` threadsafe. (Josh Cheek, #1858) +* Add version information into the JSON formatter. (Mark Swinson, #1883) +* Add `--bisect` CLI option, which will repeatedly run your suite in + order to isolate the failures to the smallest reproducible case. + (Myron Marston, #1917) +* For `config.include`, `config.extend` and `config.prepend`, apply the + module to previously defined matching example groups. (Eugene Kenny, #1935) +* When invalid options are parsed, notify users where they came from + (e.g. `.rspec` or `~/.rspec` or `ENV['SPEC_OPTS']`) so they can + easily find the source of the problem. (Myron Marston, #1940) +* Add pending message contents to the json formatter output. (Jon Rowe, #1949) +* Add shared group backtrace to the output displayed by the built-in + formatters for pending examples that have been fixed. (Myron Marston, #1946) +* Add support for `:aggregate_failures` metadata. Tag an example or + group with this metadata and it'll use rspec-expectations' + `aggregate_failures` feature to allow multiple failures in an example + and list them all, rather than aborting on the first failure. (Myron + Marston, #1946) +* When no formatter implements #message add a fallback to prevent those + messages being lost. (Jon Rowe, #1980) +* Profiling examples now takes into account time spent in `before(:context)` + hooks. (Denis Laliberté, Jon Rowe, #1971) +* Improve failure output when an example has multiple exceptions, such + as one from an `it` block and one from an `after` block. (Myron Marston, #1985) + +Bug Fixes: + +* Handle invalid UTF-8 strings within exception methods. (Benjamin Fleischer, #1760) +* Fix Rake Task quoting of file names with quotes to work properly on + Windows. (Myron Marston, #1887) +* Fix `RSpec::Core::RakeTask#failure_message` so that it gets printed + when the task failed. (Myron Marston, #1905) +* Make `let` work properly when defined in a shared context that is applied + to an individual example via metadata. (Myron Marston, #1912) +* Ensure `rspec/autorun` respects configuration defaults. (Jon Rowe, #1933) +* Prevent modules overriding example group defined methods when included, + prepended or extended by config defined after an example group. (Eugene Kenny, #1935) +* Fix regression which caused shared examples to be mistakenly run when specs + where filtered to a particular location. (Ben Axnick, #1963) +* Fix time formatting logic so that it displays 70 seconds as "1 minute, + 10 seconds" rather than "1 minute, 1 second". (Paul Brennan, #1984) +* Fix regression where the formatter loader would allow duplicate formatters. + (Jon Rowe, #1990) + +### 3.2.3 / 2015-04-06 +[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.2.2...v3.2.3) + +Bug Fixes: + +* Fix how the DSL methods are defined so that RSpec is compatible with + gems that define methods of the same name on `Kernel` (such as + the `its-it` gem). (Alex Kwiatkowski, Ryan Ong, #1907) +* Fix `before(:context) { skip }` so that it does not wrongly cause the + spec suite to exit with a non-zero status when no examples failed. + (Myron Marston, #1926) + +### 3.2.2 / 2015-03-11 +[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.2.1...v3.2.2) + +Bug Fixes: + +* Fix regression in 3.2.0 that allowed tag-filtered examples to + run even if there was a location filter applied to the spec + file that was intended to limit the file to other examples. + (#1894, Myron Marston) + +### 3.2.1 / 2015-02-23 +[Full Changelog](http://github.com/rspec/rspec-core/compare/v3.2.0...v3.2.1) + +Bug Fixes: + +* Notify start-of-run seed _before_ `start` notification rather than + _after_ so that formatters like Fuubar work properly. (Samuel Esposito, #1882) + ### 3.2.0 / 2015-02-03 [Full Changelog](http://github.com/rspec/rspec-core/compare/v3.1.7...v3.2.0) @@ -43,6 +148,13 @@ Enhancements: * Make `-I` option support multiple values when separated by `File::PATH_SEPARATOR`, such as `rspec -I foo:bar`. This matches the behavior of Ruby's `-I` option. (Fumiaki Matsushima, #1855). +* Treat each example as having a singleton example group for the + purposes of applying metadata-based features that normally apply + to example groups to individually tagged examples. For example, + `RSpec.shared_context "Uses redis", :uses_redis` will now apply + to individual examples tagged with `:uses_redis`, as will + `config.include RedisHelpers, :uses_redis`, and + `config.before(:context, :uses_redis) { }`, etc. (Myron Marston, #1749) Bug Fixes: diff --git a/README.md b/README.md index d47f17b39c..1045ca6068 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ rspec-core provides the structure for writing executable examples of how your code should behave, and an `rspec` command with tools to constrain which examples get run and tailor the output. -## install +## Install gem install rspec # for rspec-core, rspec-expectations, rspec-mocks gem install rspec-core # for rspec-core only @@ -14,12 +14,12 @@ Want to run against the `master` branch? You'll need to include the dependent RSpec repos as well. Add the following to your `Gemfile`: ```ruby -%w[rspec-core rspec-expectations rspec-mocks rspec-support].each do |lib| +%w[rspec rspec-core rspec-expectations rspec-mocks rspec-support].each do |lib| gem lib, :git => "git://github.com/rspec/#{lib}.git", :branch => 'master' end ``` -## basic structure +## Basic Structure RSpec uses the words "describe" and "it" so we can express concepts like a conversation: @@ -30,6 +30,7 @@ RSpec uses the words "describe" and "it" so we can express concepts like a conve RSpec.describe Order do it "sums the prices of its line items" do order = Order.new + order.add_entry(LineItem.new(:item => Item.new( :price => Money.new(1.11, :USD) ))) @@ -37,6 +38,7 @@ RSpec.describe Order do :price => Money.new(2.22, :USD), :quantity => 2 ))) + expect(order.total).to eq(Money.new(5.55, :USD)) end end @@ -49,7 +51,7 @@ Under the hood, an example group is a class in which the block passed to `describe` is evaluated. The blocks passed to `it` are evaluated in the context of an _instance_ of that class. -## nested groups +## Nested Groups You can also declare nested nested groups using the `describe` or `context` methods: @@ -70,7 +72,10 @@ RSpec.describe Order do end ``` -## aliases +Nested groups are subclasses of the outer example group class, providing +the inheritance semantics you'd want for free. + +## Aliases You can declare example groups using either `describe` or `context`. For a top level example group, `describe` and `context` are available @@ -81,7 +86,7 @@ patching. You can declare examples within a group using any of `it`, `specify`, or `example`. -## shared examples and contexts +## Shared Examples and Contexts Declare a shared example group using `shared_examples`, and then include it in any group using `include_examples`. @@ -111,7 +116,7 @@ pretty much the same as `shared_examples` and `include_examples`, providing more accurate naming when you share hooks, `let` declarations, helper methods, etc, but no examples. -## metadata +## Metadata rspec-core stores a metadata hash with every example and group, which contains their descriptions, the locations at which they were @@ -123,7 +128,7 @@ Although you probably won't ever need this unless you are writing an extension, you can access it from an example like this: ```ruby -it "does something" do +it "does something" do |example| expect(example.metadata[:description]).to eq("does something") end ``` @@ -162,26 +167,106 @@ RSpec.describe Hash do end ``` -## the `rspec` command +## A Word on Scope + +RSpec has two scopes: + +* **Example Group**: Example groups are defined by a `describe` or + `context` block, which is eagerly evaluated when the spec file is + loaded. The block is evaluated in the context of a subclass of + `RSpec::Core::ExampleGroup`, or a subclass of the parent example group + when you're nesting them. +* **Example**: Examples -- typically defined by an `it` block -- and any other + blocks with per-example semantics -- such as a `before(:example)` hook -- are + evaluated in the context of + an _instance_ of the example group class to which the example belongs. + Examples are _not_ executed when the spec file is loaded; instead, + RSpec waits to run any examples until all spec files have been loaded, + at which point it can apply filtering, randomization, etc. + +To make this more concrete, consider this code snippet: + +``` ruby +RSpec.describe "Using an array as a stack" do + def build_stack + [] + end + + before(:example) do + @stack = build_stack + end + + it 'is initially empty' do + expect(@stack).to be_empty + end + + context "after an item has been pushed" do + before(:example) do + @stack.push :item + end + + it 'allows the pushed item to be popped' do + expect(@stack.pop).to eq(:item) + end + end +end +``` + +Under the covers, this is (roughly) equivalent to: + +``` ruby +class UsingAnArrayAsAStack < RSpec::Core::ExampleGroup + def build_stack + [] + end + + def before_example_1 + @stack = build_stack + end + + def it_is_initially_empty + expect(@stack).to be_empty + end + + class AfterAnItemHasBeenPushed < self + def before_example_2 + @stack.push :item + end + + def it_allows_the_pushed_item_to_be_popped + expect(@stack.pop).to eq(:item) + end + end +end +``` + +To run these examples, RSpec would (roughly) do the following: + +``` ruby +example_1 = UsingAnArrayAsAStack.new +example_1.before_example_1 +example_1.it_is_initially_empty + +example_2 = UsingAnArrayAsAStack::AfterAnItemHasBeenPushed.new +example_2.before_example_1 +example_2.before_example_2 +example_2.it_allows_the_pushed_item_to_be_popped +``` + +## The `rspec` Command When you install the rspec-core gem, it installs the `rspec` executable, which you'll use to run rspec. The `rspec` command comes with many useful options. Run `rspec --help` to see the complete list. -## store command line options `.rspec` +## Store Command Line Options `.rspec` You can store command line options in a `.rspec` file in the project's root directory, and the `rspec` command will read them as though you typed them on the command line. -## autotest integration - -rspec-core no longer ships with an Autotest extension, if you require Autotest -integration, please use the `rspec-autotest` gem and see [rspec/rspec-autotest](https://github.com/rspec/rspec-autotest) -for details - -## get started +## Get Started Start with a simple example of behavior you expect from your system. Do this before you write any implementation code: @@ -204,13 +289,12 @@ $ rspec spec/calculator_spec.rb ./spec/calculator_spec.rb:1: uninitialized constant Calculator ``` -Implement the simplest solution: +Address the failure by defining a skeleton of the `Calculator` class: ```ruby # in lib/calculator.rb class Calculator - def add(a,b) - a + b + def add(a, b) end end ``` @@ -223,6 +307,39 @@ Be sure to require the implementation file in the spec: require "calculator" ``` +Now run the spec again, and watch the expectation fail: + +``` +$ rspec spec/calculator_spec.rb +F + +Failures: + + 1) Calculator#add returns the sum of its arguments + Failure/Error: expect(Calculator.new.add(1, 2)).to eq(3) + + expected: 3 + got: nil + + (compared using ==) + # ./spec/calcalator_spec.rb:6:in `block (3 levels) in ' + +Finished in 0.00131 seconds (files took 0.10968 seconds to load) +1 example, 1 failure + +Failed examples: + +rspec ./spec/calcalator_spec.rb:5 # Calculator#add returns the sum of its arguments +``` + +Implement the simplest solution, by changing the definition of `Calculator#add` to: + +```ruby +def add(a, b) + a + b +end +``` + Now run the spec again, and watch it pass: ``` diff --git a/appveyor.yml b/appveyor.yml index 9e4f457e30..8a8dab9b1b 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,4 +1,4 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. version: "{build}" @@ -18,14 +18,13 @@ install: - SET PATH=C:\Ruby%ruby_version%\bin;%PATH% - ruby --version - gem --version - # We lock to 1.7.7 to avoid warnings from 1.7.8 - - gem install bundler -v=1.7.7 + - gem install bundler - bundler --version - bundle install - cinst ansicon test_script: - - bundle exec rspec + - bundle exec rspec --backtrace environment: matrix: diff --git a/benchmarks/hash_functions.rb b/benchmarks/hash_functions.rb new file mode 100644 index 0000000000..f394542bb2 --- /dev/null +++ b/benchmarks/hash_functions.rb @@ -0,0 +1,74 @@ +require 'benchmark/ips' +require 'digest/md5' + +MAX_32_BIT = 4294967295 + +def jenkins_iterative(string) + hash = 0 + + string.each_byte do |byte| + hash += byte + hash &= MAX_32_BIT + hash += ((hash << 10) & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 6 + end + + hash += (hash << 3 & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 11 + hash += (hash << 15 & MAX_32_BIT) + hash &= MAX_32_BIT + hash +end + +def jenkins_inject(string) + hash = string.each_byte.inject(0) do |byte, hash| + hash += byte + hash &= MAX_32_BIT + hash += ((hash << 10) & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 6 + end + + hash += (hash << 3 & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 11 + hash += (hash << 15 & MAX_32_BIT) + hash &= MAX_32_BIT + hash +end + +require 'benchmark/ips' + +Benchmark.ips do |x| + x.report("md5") do + Digest::MD5.digest("string") + end + + x.report("jenkins iterative") do + jenkins_iterative("string") + end + + x.report("jenkins inject") do + jenkins_inject("string") + end + + x.compare! +end + +__END__ + +Calculating ------------------------------------- + md5 39.416k i/100ms + jenkins iterative 22.646k i/100ms + jenkins inject 18.271k i/100ms +------------------------------------------------- + md5 654.294k (±15.7%) i/s - 3.193M + jenkins iterative 349.669k (±10.3%) i/s - 1.744M + jenkins inject 286.774k (± 5.5%) i/s - 1.443M + +Comparison: + md5: 654293.8 i/s + jenkins iterative: 349668.8 i/s - 1.87x slower + jenkins inject: 286774.4 i/s - 2.28x slower diff --git a/benchmarks/keys_each_vs_each_key.rb b/benchmarks/keys_each_vs_each_key.rb new file mode 100644 index 0000000000..794bb1b35c --- /dev/null +++ b/benchmarks/keys_each_vs_each_key.rb @@ -0,0 +1,43 @@ +require 'benchmark/ips' + +small_hash = { :key => true, :more_key => true, :other_key => true } +large_hash = (1...100).inject({}) { |hash, key| hash["key_#{key}"] = true; hash } + +Benchmark.ips do |x| + x.report('keys.each with small hash') do + small_hash.keys.each { |value| value == true } + end + + x.report('each_key with small hash') do + small_hash.each_key { |value| value == true } + end + + x.report('keys.each with large hash') do + large_hash.keys.each { |value| value == true } + end + + x.report('each_key with large hash') do + large_hash.each_key { |value| value == true } + end +end + +__END__ + +Calculating ------------------------------------- +keys.each with small hash + 105.581k i/100ms +each_key with small hash + 112.045k i/100ms +keys.each with large hash + 7.625k i/100ms +each_key with large hash + 6.959k i/100ms +------------------------------------------------- +keys.each with small hash + 2.953M (± 3.8%) i/s - 14.781M +each_key with small hash + 2.917M (± 4.0%) i/s - 14.678M +keys.each with large hash + 79.349k (± 2.5%) i/s - 396.500k +each_key with large hash + 72.080k (± 2.1%) i/s - 361.868k diff --git a/benchmarks/shuffle_vs_sort_by_for_random_ordering.rb b/benchmarks/shuffle_vs_sort_by_for_random_ordering.rb new file mode 100644 index 0000000000..5014538ae4 --- /dev/null +++ b/benchmarks/shuffle_vs_sort_by_for_random_ordering.rb @@ -0,0 +1,131 @@ +require 'benchmark/ips' +require 'digest/md5' + +class Digest::Jenkins + MAX_32_BIT = 4294967295 + + def self.digest(string) + hash = 0 + + string.each_byte do |byte| + hash += byte + hash &= MAX_32_BIT + hash += ((hash << 10) & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 6 + end + + hash += (hash << 3 & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 11 + hash += (hash << 15 & MAX_32_BIT) + hash &= MAX_32_BIT + hash + end +end + +Example = Struct.new(:id) +$seed = Kernel.srand.to_s + +def shuffle_list(list) + list.shuffle +end + +def sort_using_id(list) + list.sort_by(&:id) +end + +def sort_using_md5(list) + list.sort_by { |item| Digest::MD5.digest($seed + item.id) } +end + +def sort_using_jenkins(list) + list.sort_by { |item| Digest::Jenkins.digest($seed + item.id) } +end + +[10, 100, 1000, 10000].each do |size| + puts "Size: #{size}" + list = Array.new(size) { |i| Example.new("./some_spec.rb[1:#{i}]") } + + Benchmark.ips do |x| + x.report("shuffle") { shuffle_list(list) } + x.report("use id") { sort_using_id(list) } + x.report("use md5") { sort_using_md5(list) } + x.report("use jenkins") { sort_using_md5(list) } + x.compare! + end +end + +__END__ + +Size: 10 +Calculating ------------------------------------- + shuffle 71.860k i/100ms + use id 22.562k i/100ms + use md5 4.620k i/100ms + use jenkins 4.644k i/100ms +------------------------------------------------- + shuffle 1.594M (±12.4%) i/s - 7.905M + use id 299.105k (± 7.1%) i/s - 1.489M + use md5 49.663k (± 7.5%) i/s - 249.480k + use jenkins 49.389k (± 7.5%) i/s - 246.132k + +Comparison: + shuffle: 1593820.8 i/s + use id: 299104.9 i/s - 5.33x slower + use md5: 49662.9 i/s - 32.09x slower + use jenkins: 49389.2 i/s - 32.27x slower + +Size: 100 +Calculating ------------------------------------- + shuffle 24.629k i/100ms + use id 2.076k i/100ms + use md5 477.000 i/100ms + use jenkins 483.000 i/100ms +------------------------------------------------- + shuffle 317.269k (±13.8%) i/s - 1.576M + use id 20.958k (± 4.2%) i/s - 105.876k + use md5 4.916k (± 7.5%) i/s - 24.804k + use jenkins 4.824k (± 8.6%) i/s - 24.150k + +Comparison: + shuffle: 317269.5 i/s + use id: 20957.6 i/s - 15.14x slower + use md5: 4916.5 i/s - 64.53x slower + use jenkins: 4823.5 i/s - 65.78x slower + +Size: 1000 +Calculating ------------------------------------- + shuffle 3.862k i/100ms + use id 134.000 i/100ms + use md5 44.000 i/100ms + use jenkins 44.000 i/100ms +------------------------------------------------- + shuffle 40.104k (± 4.4%) i/s - 200.824k + use id 1.424k (±13.5%) i/s - 6.968k + use md5 450.556 (± 8.0%) i/s - 2.244k + use jenkins 450.189 (± 7.6%) i/s - 2.244k + +Comparison: + shuffle: 40104.2 i/s + use id: 1423.9 i/s - 28.16x slower + use md5: 450.6 i/s - 89.01x slower + use jenkins: 450.2 i/s - 89.08x slower + +Size: 10000 +Calculating ------------------------------------- + shuffle 374.000 i/100ms + use id 10.000 i/100ms + use md5 3.000 i/100ms + use jenkins 4.000 i/100ms +------------------------------------------------- + shuffle 3.750k (± 5.4%) i/s - 18.700k + use id 109.008 (± 4.6%) i/s - 550.000 + use md5 40.614 (± 9.8%) i/s - 201.000 + use jenkins 39.975 (± 7.5%) i/s - 200.000 + +Comparison: + shuffle: 3750.0 i/s + use id: 109.0 i/s - 34.40x slower + use md5: 40.6 i/s - 92.33x slower + use jenkins: 40.0 i/s - 93.81x slower diff --git a/benchmarks/sort_by_v_shuffle.rb b/benchmarks/sort_by_v_shuffle.rb deleted file mode 100644 index 1223c90889..0000000000 --- a/benchmarks/sort_by_v_shuffle.rb +++ /dev/null @@ -1,83 +0,0 @@ -require "benchmark" - -# This benchmark demonstrates the speed of Array#shuffle versus sorting by -# random numbers. This is in reference to ordering examples using the -# --order=rand command line flag. Array#shuffle also respects seeded random via -# Kernel.srand. - -LIST = (1..1_000).to_a.freeze - -Benchmark.bmbm do |x| - x.report("sort_by") do - 1_000.times do - LIST.sort_by { Kernel.rand(LIST.size) } - end - end - - x.report("shuffle") do - 1_000.times do - LIST.shuffle - end - end - - # http://en.wikipedia.org/wiki/Fisher-Yates_shuffle - # - # We use this algorithm as an alternative to `shuffle` on - # rubies (< 1.9.3) for which Array#shuffle does not accept - # a `:random` option. We do this to avoid affecting ruby's - # global randomization. - x.report('fisher-yates') do - 1_000.times do - rng = Random.new - list = LIST.dup - LIST.size.times do |i| - j = i + rng.rand(LIST.size - i) - next if i == j - list[i], list[j] = list[j], list[i] - end - end - end -end - -=begin - -Ruby 2.0.0: - -Rehearsal ------------------------------------------------ -sort_by 0.570000 0.010000 0.580000 ( 0.581875) -shuffle 0.020000 0.000000 0.020000 ( 0.021524) -fisher-yates 0.370000 0.020000 0.390000 ( 0.387855) ---------------------------------------- total: 0.990000sec - - user system total real -sort_by 0.560000 0.000000 0.560000 ( 0.561014) -shuffle 0.010000 0.000000 0.010000 ( 0.019814) -fisher-yates 0.350000 0.010000 0.360000 ( 0.358932) - -Ruby 1.9.3: - -Rehearsal ------------------------------------------------ -sort_by 0.690000 0.010000 0.700000 ( 0.701035) -shuffle 0.020000 0.000000 0.020000 ( 0.017603) -fisher-yates 0.440000 0.020000 0.460000 ( 0.464778) ---------------------------------------- total: 1.180000sec - - user system total real -sort_by 0.690000 0.000000 0.690000 ( 0.697824) -shuffle 0.020000 0.000000 0.020000 ( 0.018622) -fisher-yates 0.440000 0.010000 0.450000 ( 0.452260) - -JRuby: - -Rehearsal ------------------------------------------------ -sort_by 2.550000 0.050000 2.600000 ( 1.325000) -shuffle 0.090000 0.000000 0.090000 ( 0.057000) -fisher-yates 0.770000 0.010000 0.780000 ( 0.477000) ---------------------------------------- total: 3.470000sec - - user system total real -sort_by 0.470000 0.010000 0.480000 ( 0.442000) -shuffle 0.040000 0.000000 0.040000 ( 0.042000) -fisher-yates 0.300000 0.010000 0.310000 ( 0.283000) - -=end diff --git a/benchmarks/threadsafe_let_block.rb b/benchmarks/threadsafe_let_block.rb new file mode 100644 index 0000000000..d0e10b4bc6 --- /dev/null +++ b/benchmarks/threadsafe_let_block.rb @@ -0,0 +1,312 @@ +require 'rspec/core' +require 'rspec/expectations' + +# switches between these implementations - https://github.com/rspec/rspec-core/pull/1858/files +# benchmark requested in this PR - https://github.com/rspec/rspec-core/pull/1858 +# +# I ran these from lib root by adding "gem 'benchmark-ips'" to ../Gemfile-custom +# then ran `bundle install --standalone --binstubs bundle/bin` +# then ran `ruby --disable-gems -I lib -I "$PWD/bundle" -r bundler/setup -S benchmarks/threadsafe_let_block.rb` + +# The old, non-thread safe implementation, imported from the `master` branch and pared down. +module OriginalNonThreadSafeMemoizedHelpers + def __memoized + @__memoized ||= {} + end + + module ClassMethods + def let(name, &block) + # We have to pass the block directly to `define_method` to + # allow it to use method constructs like `super` and `return`. + raise "#let or #subject called without a block" if block.nil? + OriginalNonThreadSafeMemoizedHelpers.module_for(self).__send__(:define_method, name, &block) + + # Apply the memoization. The method has been defined in an ancestor + # module so we can use `super` here to get the value. + if block.arity == 1 + define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } } + else + define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } } + end + end + end + + def self.module_for(example_group) + get_constant_or_yield(example_group, :LetDefinitions) do + mod = Module.new do + include Module.new { + example_group.const_set(:NamedSubjectPreventSuper, self) + } + end + + example_group.const_set(:LetDefinitions, mod) + mod + end + end + + # @private + def self.define_helpers_on(example_group) + example_group.__send__(:include, module_for(example_group)) + end + + def self.get_constant_or_yield(example_group, name) + if example_group.const_defined?(name, (check_ancestors = false)) + example_group.const_get(name, check_ancestors) + else + yield + end + end +end + +class HostBase + # wires the implementation + # adds `let(:name) { nil }` + # returns `Class.new(self) { let(:name) { super() } }` + def self.prepare_using(memoized_helpers, options={}) + include memoized_helpers + extend memoized_helpers::ClassMethods + memoized_helpers.define_helpers_on(self) + + define_method(:initialize, &options[:initialize]) if options[:initialize] + let(:name) { nil } + + verify_memoizes memoized_helpers, options[:verify] + + Class.new(self) do + memoized_helpers.define_helpers_on(self) + let(:name) { super() } + end + end + + def self.verify_memoizes(memoized_helpers, additional_verification) + # Since we're using custom code, ensure it actually memoizes as we expect... + counter_class = Class.new(self) do + include RSpec::Matchers + memoized_helpers.define_helpers_on(self) + counter = 0 + let(:count) { counter += 1 } + end + extend RSpec::Matchers + + instance_1 = counter_class.new + expect(instance_1.count).to eq(1) + expect(instance_1.count).to eq(1) + + instance_2 = counter_class.new + expect(instance_2.count).to eq(2) + expect(instance_2.count).to eq(2) + + instance_3 = counter_class.new + instance_3.instance_eval &additional_verification if additional_verification + end +end + +class OriginalNonThreadSafeHost < HostBase + Subclass = prepare_using OriginalNonThreadSafeMemoizedHelpers +end + +class ThreadSafeHost < HostBase + Subclass = prepare_using RSpec::Core::MemoizedHelpers, + :initialize => lambda { |*| @__memoized = ThreadsafeMemoized.new }, + :verify => lambda { |*| expect(__memoized).to be_a_kind_of RSpec::Core::MemoizedHelpers::ThreadsafeMemoized } +end + +class ConfigNonThreadSafeHost < HostBase + Subclass = prepare_using RSpec::Core::MemoizedHelpers, + :initialize => lambda { |*| @__memoized = NonThreadSafeMemoized.new }, + :verify => lambda { |*| expect(__memoized).to be_a_kind_of RSpec::Core::MemoizedHelpers::NonThreadSafeMemoized } +end + +def title(title) + hr = "#" * (title.length + 6) + blank = "# #{' ' * title.length} #" + [hr, blank, "# #{title} #", blank, hr] +end + +require 'benchmark/ips' + +puts title "versions" +puts "RUBY_VERSION #{RUBY_VERSION}" +puts "RUBY_PLATFORM #{RUBY_PLATFORM}" +puts "RUBY_ENGINE #{RUBY_ENGINE}" +puts "ruby -v #{`ruby -v`}" +puts "Benchmark::IPS::VERSION #{Benchmark::IPS::VERSION}" +puts "rspec-core SHA #{`git log --pretty=format:%H -1`}" +puts + +puts title "1 call to let -- each sets the value" +Benchmark.ips do |x| + x.report("non-threadsafe (original)") { OriginalNonThreadSafeHost.new.name } + x.report("non-threadsafe (config) ") { ConfigNonThreadSafeHost.new.name } + x.report("threadsafe ") { ThreadSafeHost.new.name } + x.compare! +end + +puts title "10 calls to let -- 9 will find memoized value" +Benchmark.ips do |x| + x.report("non-threadsafe (original)") do + i = OriginalNonThreadSafeHost.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.report("non-threadsafe (config) ") do + i = ConfigNonThreadSafeHost.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.report("threadsafe ") do + i = ThreadSafeHost.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.compare! +end + +puts title "1 call to let which invokes super" + +Benchmark.ips do |x| + x.report("non-threadsafe (original)") { OriginalNonThreadSafeHost::Subclass.new.name } + x.report("non-threadsafe (config) ") { ConfigNonThreadSafeHost::Subclass.new.name } + x.report("threadsafe ") { ThreadSafeHost::Subclass.new.name } + x.compare! +end + +puts title "10 calls to let which invokes super" +Benchmark.ips do |x| + x.report("non-threadsafe (original)") do + i = OriginalNonThreadSafeHost::Subclass.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.report("non-threadsafe (config) ") do + i = ConfigNonThreadSafeHost::Subclass.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.report("threadsafe ") do + i = ThreadSafeHost::Subclass.new + i.name; i.name; i.name; i.name; i.name + i.name; i.name; i.name; i.name; i.name + end + + x.compare! +end + +__END__ + +############## +# # +# versions # +# # +############## +RUBY_VERSION 2.2.0 +RUBY_PLATFORM x86_64-darwin13 +RUBY_ENGINE ruby +ruby -v ruby 2.2.0p0 (2014-12-25 revision 49005) [x86_64-darwin13] +Benchmark::IPS::VERSION 2.1.1 +rspec-core SHA 1ee7a8d8cde6ba2dd13d35e90e824e8e5ba7db76 + +########################################## +# # +# 1 call to let -- each sets the value # +# # +########################################## +Calculating ------------------------------------- +non-threadsafe (original) + 53.722k i/100ms +non-threadsafe (config) + 44.998k i/100ms +threadsafe + 26.123k i/100ms +------------------------------------------------- +non-threadsafe (original) + 830.988k (± 6.3%) i/s - 4.190M +non-threadsafe (config) + 665.662k (± 6.7%) i/s - 3.330M +threadsafe + 323.575k (± 5.6%) i/s - 1.620M + +Comparison: +non-threadsafe (original): 830988.5 i/s +non-threadsafe (config) : 665661.9 i/s - 1.25x slower +threadsafe : 323574.9 i/s - 2.57x slower + +################################################### +# # +# 10 calls to let -- 9 will find memoized value # +# # +################################################### +Calculating ------------------------------------- +non-threadsafe (original) + 28.724k i/100ms +non-threadsafe (config) + 25.357k i/100ms +threadsafe + 18.349k i/100ms +------------------------------------------------- +non-threadsafe (original) + 346.302k (± 6.1%) i/s - 1.752M +non-threadsafe (config) + 309.970k (± 5.4%) i/s - 1.547M +threadsafe + 208.946k (± 5.2%) i/s - 1.046M + +Comparison: +non-threadsafe (original): 346302.0 i/s +non-threadsafe (config) : 309970.2 i/s - 1.12x slower +threadsafe : 208946.3 i/s - 1.66x slower + +####################################### +# # +# 1 call to let which invokes super # +# # +####################################### +Calculating ------------------------------------- +non-threadsafe (original) + 42.458k i/100ms +non-threadsafe (config) + 37.367k i/100ms +threadsafe + 21.088k i/100ms +------------------------------------------------- +non-threadsafe (original) + 591.906k (± 6.3%) i/s - 2.972M +non-threadsafe (config) + 511.295k (± 4.7%) i/s - 2.578M +threadsafe + 246.080k (± 5.8%) i/s - 1.244M + +Comparison: +non-threadsafe (original): 591906.3 i/s +non-threadsafe (config) : 511295.0 i/s - 1.16x slower +threadsafe : 246079.6 i/s - 2.41x slower + +######################################### +# # +# 10 calls to let which invokes super # +# # +######################################### +Calculating ------------------------------------- +non-threadsafe (original) + 24.282k i/100ms +non-threadsafe (config) + 22.762k i/100ms +threadsafe + 14.685k i/100ms +------------------------------------------------- +non-threadsafe (original) + 297.423k (± 5.0%) i/s - 1.505M +non-threadsafe (config) + 264.046k (± 5.6%) i/s - 1.320M +threadsafe + 170.853k (± 4.7%) i/s - 866.415k + +Comparison: +non-threadsafe (original): 297422.6 i/s +non-threadsafe (config) : 264045.8 i/s - 1.13x slower +threadsafe : 170853.1 i/s - 1.74x slower diff --git a/features/.nav b/features/.nav index 415b7d0b57..212dfb5751 100644 --- a/features/.nav +++ b/features/.nav @@ -46,6 +46,7 @@ - default_path.feature - expectation_framework_integration: - configure_expectation_framework.feature + - failure_aggregation.feature - mock_framework_integration: - use_rspec.feature - use_flexmock.feature diff --git a/features/command_line/bisect.feature b/features/command_line/bisect.feature new file mode 100644 index 0000000000..d56df3c1d2 --- /dev/null +++ b/features/command_line/bisect.feature @@ -0,0 +1,161 @@ +Feature: Bisect + + RSpec's `--order random` and `--seed` options help surface flickering examples that only fail when one or more other examples are executed first. It can be very difficult to isolate the exact combination of examples that triggers the failure. The `--bisect` flag helps solve that problem. + + Pass the `--bisect` option (in addition to `--seed` and any other options) and RSpec will repeatedly run subsets of your suite in order to isolate the minimal set of examples that reproduce the same failures. + + At any point during the bisect run, you can hit ctrl-c to abort and it will provide you with the most minimal reproduction command it has discovered so far. + + To get more detailed output (particularly useful if you want to report a bug with bisect), use `--bisect=verbose`. + + Background: + Given a file named "lib/calculator.rb" with: + """ruby + class Calculator + def self.add(x, y) + x + y + end + end + """ + And a file named "spec/calculator_1_spec.rb" with: + """ruby + require 'calculator' + + RSpec.describe "Calculator" do + it 'adds numbers' do + expect(Calculator.add(1, 2)).to eq(3) + end + end + """ + And files "spec/calculator_2_spec.rb" through "spec/calculator_9_spec.rb" with an unrelated passing spec in each file + And a file named "spec/calculator_10_spec.rb" with: + """ruby + require 'calculator' + + RSpec.describe "Monkey patched Calculator" do + it 'does screwy math' do + # monkey patching `Calculator` affects examples that are + # executed after this one! + def Calculator.add(x, y) + x - y + end + + expect(Calculator.add(5, 10)).to eq(-5) + end + end + """ + + Scenario: Use `--bisect` flag to create a minimal repro case for the ordering dependency + When I run `rspec --seed 1234` + Then the output should contain "10 examples, 1 failure" + When I run `rspec --seed 1234 --bisect` + Then bisect should succeed with output like: + """ + Bisect started using options: "--seed 1234" + Running suite to find failures... (0.16755 seconds) + Starting bisect with 1 failing example and 9 non-failing examples. + + Round 1: searching for 5 non-failing examples (of 9) to ignore: .. (0.30166 seconds) + Round 2: searching for 3 non-failing examples (of 5) to ignore: .. (0.30306 seconds) + Round 3: searching for 2 non-failing examples (of 3) to ignore: .. (0.33292 seconds) + Round 4: searching for 1 non-failing example (of 2) to ignore: . (0.16476 seconds) + Round 5: searching for 1 non-failing example (of 1) to ignore: . (0.15329 seconds) + Bisect complete! Reduced necessary non-failing examples from 9 to 1 in 1.26 seconds. + + The minimal reproduction command is: + rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234 + """ + When I run `rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234` + Then the output should contain "2 examples, 1 failure" + + Scenario: Ctrl-C can be used to abort the bisect early and get the most minimal command it has discovered so far + When I run `rspec --seed 1234 --bisect` and abort in the middle with ctrl-c + Then bisect should fail with output like: + """ + Bisect started using options: "--seed 1234" + Running suite to find failures... (0.17102 seconds) + Starting bisect with 1 failing example and 9 non-failing examples. + + Round 1: searching for 5 non-failing examples (of 9) to ignore: .. (0.32943 seconds) + Round 2: searching for 3 non-failing examples (of 5) to ignore: .. (0.3154 seconds) + Round 3: searching for 2 non-failing examples (of 3) to ignore: .. + + Bisect aborted! + + The most minimal reproduction command discovered so far is: + rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_3_spec.rb[1:1] --seed 1234 + """ + When I run `rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_3_spec.rb[1:1] --seed 1234` + Then the output should contain "3 examples, 1 failure" + + Scenario: Use `--bisect=verbose` to enable verbose debug mode for more detail + When I run `rspec --seed 1234 --bisect=verbose` + Then bisect should succeed with output like: + """ + Bisect started using options: "--seed 1234" + Running suite to find failures... (0.16528 seconds) + - Failing examples (1): + - ./spec/calculator_1_spec.rb[1:1] + - Non-failing examples (9): + - ./spec/calculator_10_spec.rb[1:1] + - ./spec/calculator_2_spec.rb[1:1] + - ./spec/calculator_3_spec.rb[1:1] + - ./spec/calculator_4_spec.rb[1:1] + - ./spec/calculator_5_spec.rb[1:1] + - ./spec/calculator_6_spec.rb[1:1] + - ./spec/calculator_7_spec.rb[1:1] + - ./spec/calculator_8_spec.rb[1:1] + - ./spec/calculator_9_spec.rb[1:1] + + Round 1: searching for 5 non-failing examples (of 9) to ignore: + - Running: rspec ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_6_spec.rb[1:1] ./spec/calculator_7_spec.rb[1:1] ./spec/calculator_8_spec.rb[1:1] ./spec/calculator_9_spec.rb[1:1] --seed 1234 (0.15302 seconds) + - Running: rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_2_spec.rb[1:1] ./spec/calculator_3_spec.rb[1:1] ./spec/calculator_4_spec.rb[1:1] ./spec/calculator_5_spec.rb[1:1] --seed 1234 (0.19708 seconds) + - Examples we can safely ignore (4): + - ./spec/calculator_6_spec.rb[1:1] + - ./spec/calculator_7_spec.rb[1:1] + - ./spec/calculator_8_spec.rb[1:1] + - ./spec/calculator_9_spec.rb[1:1] + - Remaining non-failing examples (5): + - ./spec/calculator_10_spec.rb[1:1] + - ./spec/calculator_2_spec.rb[1:1] + - ./spec/calculator_3_spec.rb[1:1] + - ./spec/calculator_4_spec.rb[1:1] + - ./spec/calculator_5_spec.rb[1:1] + - Round finished (0.35172 seconds) + Round 2: searching for 3 non-failing examples (of 5) to ignore: + - Running: rspec ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_4_spec.rb[1:1] ./spec/calculator_5_spec.rb[1:1] --seed 1234 (0.15836 seconds) + - Running: rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_2_spec.rb[1:1] ./spec/calculator_3_spec.rb[1:1] --seed 1234 (0.19065 seconds) + - Examples we can safely ignore (2): + - ./spec/calculator_4_spec.rb[1:1] + - ./spec/calculator_5_spec.rb[1:1] + - Remaining non-failing examples (3): + - ./spec/calculator_10_spec.rb[1:1] + - ./spec/calculator_2_spec.rb[1:1] + - ./spec/calculator_3_spec.rb[1:1] + - Round finished (0.35022 seconds) + Round 3: searching for 2 non-failing examples (of 3) to ignore: + - Running: rspec ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_2_spec.rb[1:1] --seed 1234 (0.21028 seconds) + - Running: rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] ./spec/calculator_3_spec.rb[1:1] --seed 1234 (0.1975 seconds) + - Examples we can safely ignore (1): + - ./spec/calculator_2_spec.rb[1:1] + - Remaining non-failing examples (2): + - ./spec/calculator_10_spec.rb[1:1] + - ./spec/calculator_3_spec.rb[1:1] + - Round finished (0.40882 seconds) + Round 4: searching for 1 non-failing example (of 2) to ignore: + - Running: rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234 (0.17173 seconds) + - Examples we can safely ignore (1): + - ./spec/calculator_3_spec.rb[1:1] + - Remaining non-failing examples (1): + - ./spec/calculator_10_spec.rb[1:1] + - Round finished (0.17234 seconds) + Round 5: searching for 1 non-failing example (of 1) to ignore: + - Running: rspec ./spec/calculator_1_spec.rb[1:1] --seed 1234 (0.18279 seconds) + - Round finished (0.18312 seconds) + Bisect complete! Reduced necessary non-failing examples from 9 to 1 in 1.47 seconds. + + The minimal reproduction command is: + rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234 + """ + When I run `rspec ./spec/calculator_10_spec.rb[1:1] ./spec/calculator_1_spec.rb[1:1] --seed 1234` + Then the output should contain "2 examples, 1 failure" diff --git a/features/command_line/only_failures.feature b/features/command_line/only_failures.feature new file mode 100644 index 0000000000..4d31817600 --- /dev/null +++ b/features/command_line/only_failures.feature @@ -0,0 +1,114 @@ +Feature: Only Failures + + The `--only-failures` option filters what examples are run so that only those that failed the last time they ran are executed. To use this option, you first have to configure `config.example_status_persistence_file_path`, which RSpec will use to store the status of each example the last time it ran. + + There's also a `--next-failure` option, which is shorthand for `--only-failures --fail-fast --order defined`. It allows you to repeatedly focus on just one of the currently failing examples, then move on to the next failure, etc. + + Either of these options can be combined with another a directory or file name; RSpec will run just the failures from the set of loaded examples. + + Background: + Given a file named "spec/spec_helper.rb" with: + """ruby + RSpec.configure do |c| + c.example_status_persistence_file_path = "examples.txt" + c.run_all_when_everything_filtered = true + end + """ + And a file named ".rspec" with: + """ + --require spec_helper + --order random + --format documentation + """ + And a file named "spec/array_spec.rb" with: + """ruby + RSpec.describe 'Array' do + it "checks for inclusion of 1" do + expect([1, 2]).to include(1) + end + + it "checks for inclusion of 2" do + expect([1, 2]).to include(2) + end + + it "checks for inclusion of 3" do + expect([1, 2]).to include(3) # failure + end + end + """ + And a file named "spec/string_spec.rb" with: + """ruby + RSpec.describe 'String' do + it "checks for inclusion of 'foo'" do + expect("food").to include('foo') + end + + it "checks for inclusion of 'bar'" do + expect("food").to include('bar') # failure + end + + it "checks for inclusion of 'baz'" do + expect("bazzy").to include('baz') + end + + it "checks for inclusion of 'foobar'" do + expect("food").to include('foobar') # failure + end + end + """ + And a file named "spec/passing_spec.rb" with: + """ruby + puts "Loading passing_spec.rb" + + RSpec.describe "A passing spec" do + it "passes" do + expect(1).to eq(1) + end + end + """ + And I have run `rspec` once, resulting in "8 examples, 3 failures" + + Scenario: Running `rspec --only-failures` loads only spec files with failures and runs only the failures + When I run `rspec --only-failures` + Then the output from "rspec --only-failures" should contain "3 examples, 3 failures" + And the output from "rspec --only-failures" should not contain "Loading passing_spec.rb" + + Scenario: Combine `--only-failures` with a file name + When I run `rspec spec/array_spec.rb --only-failures` + Then the output should contain "1 example, 1 failure" + When I run `rspec spec/string_spec.rb --only-failures` + Then the output should contain "2 examples, 2 failures" + + Scenario: Use `--next-failure` to repeatedly run a single failure + When I run `rspec --next-failure` + Then the output should contain "1 example, 1 failure" + And the output should contain "checks for inclusion of 3" + + When I fix "spec/array_spec.rb" by replacing "to include(3)" with "not_to include(3)" + And I run `rspec --next-failure` + Then the output should contain "2 examples, 1 failure" + And the output should contain "checks for inclusion of 3" + And the output should contain "checks for inclusion of 'bar'" + + When I fix "spec/string_spec.rb" by replacing "to include('bar')" with "not_to include('bar')" + And I run `rspec --next-failure` + Then the output should contain "2 examples, 1 failure" + And the output should contain "checks for inclusion of 'bar'" + And the output should contain "checks for inclusion of 'foobar'" + + When I fix "spec/string_spec.rb" by replacing "to include('foobar')" with "not_to include('foobar')" + And I run `rspec --next-failure` + Then the output should contain "1 example, 0 failures" + And the output should contain "checks for inclusion of 'foobar'" + + When I run `rspec --next-failure` + Then the output should contain "All examples were filtered out" + + Scenario: Running `rspec --only-failures` with spec files that pass doesn't run anything + When I run `rspec spec/passing_spec.rb --only-failures` + Then it should pass with "0 examples, 0 failures" + + Scenario: Clear error given when using `--only-failures` without configuring `example_status_persistence_file_path` + Given I have not configured `example_status_persistence_file_path` + When I run `rspec --only-failures` + Then it should fail with "To use `--only-failures`, you must first set `config.example_status_persistence_file_path`." diff --git a/features/configuration/enable_global_dsl.feature b/features/configuration/enable_global_dsl.feature index 3009c80b95..cf0d38f0ea 100644 --- a/features/configuration/enable_global_dsl.feature +++ b/features/configuration/enable_global_dsl.feature @@ -24,10 +24,11 @@ Feature: Global namespace DSL For backwards compatibility it defaults to `true`. + @allow-should-syntax Scenario: By default RSpec allows the DSL to be used globally Given a file named "spec/example_spec.rb" with: """ruby - RSpec.describe "specs here" do + describe "specs here" do it "passes" do end end @@ -35,6 +36,19 @@ Feature: Global namespace DSL When I run `rspec` Then the output should contain "1 example, 0 failures" + @allow-should-syntax + Scenario: By default rspec/autorun allows the DSL to be used globally + Given a file named "spec/example_spec.rb" with: + """ruby + require 'rspec/autorun' + describe "specs here" do + it "passes" do + end + end + """ + When I run `ruby spec/example_spec.rb` + Then the output should contain "1 example, 0 failures" + Scenario: When exposing globally is disabled the top level DSL no longer works Given a file named "spec/example_spec.rb" with: """ruby diff --git a/features/configuration/profile.feature b/features/configuration/profile.feature index 402d79d731..7d2d6d1677 100644 --- a/features/configuration/profile.feature +++ b/features/configuration/profile.feature @@ -218,3 +218,28 @@ Feature: Profile examples When I run `rspec spec --fail-fast --profile` Then the output should not contain "Top 2 slowest examples" And the output should not contain "example 1" + + Scenario: Using `--profile` with slow before hooks includes hook execution time + Given a file named "spec/example_spec.rb" with: + """ruby + RSpec.describe "slow before context hook" do + before(:context) do + sleep 0.2 + end + + context "nested" do + it "example" do + expect(10).to eq(10) + end + end + end + + RSpec.describe "slow example" do + it "slow example" do + sleep 0.1 + expect(10).to eq(10) + end + end + """ + When I run `rspec spec --profile 1` + Then the output should report "slow before context hook" as the slowest example group diff --git a/features/core_standalone.feature b/features/core_standalone.feature index a583563dae..53523d9abf 100644 --- a/features/core_standalone.feature +++ b/features/core_standalone.feature @@ -5,6 +5,8 @@ Feature: Use rspec-core without rspec-mocks or rspec-expectations available, but rspec-core can be used just fine without either of those gems installed. + # Rubinius stacktrace includes kernel/loader.rb etc. + @unsupported-on-rbx Scenario: Use only rspec-core when only it is installed Given only rspec-core is installed And a file named "core_only_spec.rb" with: diff --git a/features/example_groups/shared_context.feature b/features/example_groups/shared_context.feature index c21e23861e..5f90d7a82f 100644 --- a/features/example_groups/shared_context.feature +++ b/features/example_groups/shared_context.feature @@ -4,7 +4,7 @@ Feature: shared context of example groups either explicitly, using `include_context`, or implicitly by matching metadata. - When implicitly including shared contexts via matching metadata, the normal way is to define matching metadata on an example group (in which case the ontext is included in the entire group), but you can also include it in an individual example. RSpec treats every example as having a singleton example group (analogous to Ruby's singleton classes) containing just the one example. + When implicitly including shared contexts via matching metadata, the normal way is to define matching metadata on an example group, in which case the context is included in the entire group. However, you also have the option to include it in an individual example instead. RSpec treats every example as having a singleton example group (analogous to Ruby's singleton classes) containing just the one example. Background: Given a file named "shared_stuff.rb" with: diff --git a/features/example_groups/shared_examples.feature b/features/example_groups/shared_examples.feature index 27563be13a..cbb97244e2 100644 --- a/features/example_groups/shared_examples.feature +++ b/features/example_groups/shared_examples.feature @@ -19,6 +19,50 @@ Feature: shared examples anything special (like autoload). Doing so would require a strict naming convention for files that would break existing suites. + **WARNING:** When you include parameterized examples in the current context multiple + times, you may override previous method definitions and last declaration wins. + So if you have this kind of shared example (or shared context) + + ```ruby + RSpec.shared_examples "some example" do |parameter| + \# Same behavior is triggered also with either `def something; 'some value'; end` + \# or `define_method(:something) { 'some value' }` + let(:something) { parameter } + it "uses the given parameter" do + expect(something).to eq(parameter) + end + end + + RSpec.describe SomeClass do + include_example "some example", "parameter1" + include_example "some example", "parameter2" + end + ``` + + You're actually doing this (notice that first example will fail): + + ```ruby + RSpec.describe SomeClass do + \# Reordered code for better understanding of what is happening + let(:something) { "parameter1" } + let(:something) { "parameter2" } + + it "uses the given parameter" do + \# This example will fail because last let "wins" + expect(something).to eq("parameter1") + end + + it "uses the given parameter" do + expect(something).to eq("parameter2") + end + end + ``` + + To prevent this kind of subtle error a warning is emitted if you declare multiple + methods with the same name in the same context. Should you get this warning + the simplest solution is to replace `include_example` with `it_behaves_like`, in this + way method overriding is avoided because of the nested context created by `it_behaves_like` + Conventions: ------------ @@ -60,13 +104,13 @@ Feature: shared examples end describe "#include?" do - context "with an an item that is in the collection" do + context "with an item that is in the collection" do it "returns true" do expect(collection.include?(7)).to be_truthy end end - context "with an an item that is not in the collection" do + context "with an item that is not in the collection" do it "returns false" do expect(collection.include?(9)).to be_falsey end @@ -91,9 +135,9 @@ Feature: shared examples initialized with 3 items says it has three items #include? - with an an item that is in the collection + with an item that is in the collection returns true - with an an item that is not in the collection + with an item that is not in the collection returns false Set @@ -101,9 +145,9 @@ Feature: shared examples initialized with 3 items says it has three items #include? - with an an item that is in the collection + with an item that is in the collection returns true - with an an item that is not in the collection + with an item that is not in the collection returns false """ diff --git a/features/expectation_framework_integration/aggregating_failures.feature b/features/expectation_framework_integration/aggregating_failures.feature new file mode 100644 index 0000000000..aa0d294029 --- /dev/null +++ b/features/expectation_framework_integration/aggregating_failures.feature @@ -0,0 +1,352 @@ +Feature: Aggregating Failures + + RSpec::Expectations provides [`aggregate_failures`](../../../rspec-expectations/docs/aggregating-failures), an API that allows you to group a set of expectations and see all the failures at once, rather than it aborting on the first failure. RSpec::Core improves on this feature in a couple of ways: + + * RSpec::Core provides much better failure output, adding code snippets and backtraces to the sub-failures, just like it does for any normal failure. + * RSpec::Core provides [metadata](../metadata/user-defined-metadata) integration for this feature. Each example that is tagged with `:aggregate_failures` will be wrapped in an `aggregate_failures` block. You can also use `config.define_derived_metadata` to apply this to every example automatically. + + The metadata form is quite convenient, but may not work well for end-to-end tests that have multiple distinct steps. For example, consider a spec for an HTTP client workflow that (1) makes a request, (2) expects a redirect, (3) follows the redirect, and (4) expects a particular response. You probably want the `expect(response.status).to be_between(300, 399)` expectation to immediately abort if it fails, because you can't perform the next step (following the redirect) if that is not satisfied. For these situations, we encourage you to use the `aggregate_failures` block form to wrap each set of expectations that represents a distinct step in the test workflow. + + Background: + Given a file named "lib/client.rb" with: + """ruby + Response = Struct.new(:status, :headers, :body) + + class Client + def self.make_request(url='/') + Response.new(404, { "Content-Type" => "text/plain" }, "Not Found") + end + end + """ + + Scenario: Use `aggregate_failures` block form + Given a file named "spec/use_block_form_spec.rb" with: + """ruby + require 'client' + + RSpec.describe Client do + after do + # this should be appended to failure list + expect(false).to be(true), "after hook failure" + end + + around do |ex| + ex.run + # this should also be appended to failure list + expect(false).to be(true), "around hook failure" + end + + it "returns a successful response" do + response = Client.make_request + + aggregate_failures "testing response" do + expect(response.status).to eq(200) + expect(response.headers).to include("Content-Type" => "application/json") + expect(response.body).to eq('{"message":"Success"}') + end + end + end + """ + When I run `rspec spec/use_block_form_spec.rb` + Then it should fail and list all the failures: + """ + Failures: + + 1) Client returns a successful response + Got 3 failures: + + 1.1) Got 3 failures from failure aggregation block "testing response". + # ./spec/use_block_form_spec.rb:18:in `block (2 levels) in ' + # ./spec/use_block_form_spec.rb:10:in `block (2 levels) in ' + + 1.1.1) Failure/Error: expect(response.status).to eq(200) + + expected: 200 + got: 404 + + (compared using ==) + # ./spec/use_block_form_spec.rb:19:in `block (3 levels) in ' + + 1.1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json") + expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"} + Diff: + @@ -1,2 +1,2 @@ + -[{"Content-Type"=>"application/json"}] + +"Content-Type" => "text/plain", + # ./spec/use_block_form_spec.rb:20:in `block (3 levels) in ' + + 1.1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}') + + expected: "{\"message\":\"Success\"}" + got: "Not Found" + + (compared using ==) + # ./spec/use_block_form_spec.rb:21:in `block (3 levels) in ' + + 1.2) Failure/Error: expect(false).to be(true), "after hook failure" + after hook failure + # ./spec/use_block_form_spec.rb:6:in `block (2 levels) in ' + # ./spec/use_block_form_spec.rb:10:in `block (2 levels) in ' + + 1.3) Failure/Error: expect(false).to be(true), "around hook failure" + around hook failure + # ./spec/use_block_form_spec.rb:12:in `block (2 levels) in ' + """ + + Scenario: Use `:aggregate_failures` metadata + Given a file named "spec/use_metadata_spec.rb" with: + """ruby + require 'client' + + RSpec.describe Client do + it "follows a redirect", :aggregate_failures do + response = Client.make_request + + expect(response.status).to eq(302) + expect(response.body).to eq('{"message":"Redirect"}') + + redirect_response = Client.make_request(response.headers.fetch('Location')) + + expect(redirect_response.status).to eq(200) + expect(redirect_response.body).to eq('{"message":"OK"}') + end + end + """ + When I run `rspec spec/use_metadata_spec.rb` + Then it should fail and list all the failures: + """ + Failures: + + 1) Client follows a redirect + Got 2 failures and 1 other error: + + 1.1) Failure/Error: expect(response.status).to eq(302) + + expected: 302 + got: 404 + + (compared using ==) + # ./spec/use_metadata_spec.rb:7:in `block (2 levels) in ' + + 1.2) Failure/Error: expect(response.body).to eq('{"message":"Redirect"}') + + expected: "{\"message\":\"Redirect\"}" + got: "Not Found" + + (compared using ==) + # ./spec/use_metadata_spec.rb:8:in `block (2 levels) in ' + + 1.3) Failure/Error: redirect_response = Client.make_request(response.headers.fetch('Location')) + KeyError: + key not found: "Location" + # ./spec/use_metadata_spec.rb:10:in `fetch' + # ./spec/use_metadata_spec.rb:10:in `block (2 levels) in ' + """ + + Scenario: Enable failure aggregation globally using `define_derived_metadata` + Given a file named "spec/enable_globally_spec.rb" with: + """ruby + require 'client' + + RSpec.configure do |c| + c.define_derived_metadata do |meta| + meta[:aggregate_failures] = true + end + end + + RSpec.describe Client do + it "returns a successful response" do + response = Client.make_request + + expect(response.status).to eq(200) + expect(response.headers).to include("Content-Type" => "application/json") + expect(response.body).to eq('{"message":"Success"}') + end + end + """ + When I run `rspec spec/enable_globally_spec.rb` + Then it should fail and list all the failures: + """ + Failures: + + 1) Client returns a successful response + Got 3 failures: + + 1.1) Failure/Error: expect(response.status).to eq(200) + + expected: 200 + got: 404 + + (compared using ==) + # ./spec/enable_globally_spec.rb:13 + + 1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json") + expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"} + Diff: + @@ -1,2 +1,2 @@ + -[{"Content-Type"=>"application/json"}] + +"Content-Type" => "text/plain", + # ./spec/enable_globally_spec.rb:14 + + 1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}') + + expected: "{\"message\":\"Success\"}" + got: "Not Found" + + (compared using ==) + # ./spec/enable_globally_spec.rb:15 + """ + + Scenario: Nested failure aggregation works + Given a file named "spec/nested_failure_aggregation_spec.rb" with: + """ruby + require 'client' + + RSpec.describe Client do + it "returns a successful response", :aggregate_failures do + response = Client.make_request + + expect(response.status).to eq(200) + + aggregate_failures "testing headers" do + expect(response.headers).to include("Content-Type" => "application/json") + expect(response.headers).to include("Content-Length" => "21") + end + + expect(response.body).to eq('{"message":"Success"}') + end + end + """ + When I run `rspec spec/nested_failure_aggregation_spec.rb` + Then it should fail and list all the failures: + """ + Failures: + + 1) Client returns a successful response + Got 3 failures: + + 1.1) Failure/Error: expect(response.status).to eq(200) + + expected: 200 + got: 404 + + (compared using ==) + # ./spec/nested_failure_aggregation_spec.rb:7 + + 1.2) Got 2 failures from failure aggregation block "testing headers". + # ./spec/nested_failure_aggregation_spec.rb:9 + + 1.2.1) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json") + expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"} + Diff: + @@ -1,2 +1,2 @@ + -[{"Content-Type"=>"application/json"}] + +"Content-Type" => "text/plain", + # ./spec/nested_failure_aggregation_spec.rb:10 + + 1.2.2) Failure/Error: expect(response.headers).to include("Content-Length" => "21") + expected {"Content-Type" => "text/plain"} to include {"Content-Length" => "21"} + Diff: + @@ -1,2 +1,2 @@ + -[{"Content-Length"=>"21"}] + +"Content-Type" => "text/plain", + # ./spec/nested_failure_aggregation_spec.rb:11 + + 1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}') + + expected: "{\"message\":\"Success\"}" + got: "Not Found" + + (compared using ==) + # ./spec/nested_failure_aggregation_spec.rb:14 + """ + + Scenario: Mock expectation failures are aggregated as well + Given a file named "spec/mock_expectation_failure_spec.rb" with: + """ruby + require 'client' + + RSpec.describe "Aggregating Failures", :aggregate_failures do + it "has a normal expectation failure and a message expectation failure" do + client = double("Client") + expect(client).to receive(:put).with("updated data") + allow(client).to receive(:get).and_return(Response.new(404, {}, "Not Found")) + + response = client.get + expect(response.status).to eq(200) + end + end + """ + When I run `rspec spec/mock_expectation_failure_spec.rb` + Then it should fail and list all the failures: + """ + Failures: + + 1) Aggregating Failures has a normal expectation failure and a message expectation failure + Got 2 failures: + + 1.1) Failure/Error: expect(response.status).to eq(200) + + expected: 200 + got: 404 + + (compared using ==) + # ./spec/mock_expectation_failure_spec.rb:10 + + 1.2) Failure/Error: expect(client).to receive(:put).with("updated data") + (Double "Client").put("updated data") + expected: 1 time with arguments: ("updated data") + received: 0 times + # ./spec/mock_expectation_failure_spec.rb:6 + + """ + + Scenario: Pending integrates properly with aggregated failures + Given a file named "spec/pending_spec.rb" with: + """ruby + require 'client' + + RSpec.describe Client do + it "returns a successful response", :aggregate_failures do + pending "Not yet ready" + response = Client.make_request + + expect(response.status).to eq(200) + expect(response.headers).to include("Content-Type" => "application/json") + expect(response.body).to eq('{"message":"Success"}') + end + end + """ + When I run `rspec spec/pending_spec.rb` + Then it should pass and list all the pending examples: + """ + Pending: (Failures listed here are expected and do not affect your suite's status) + + 1) Client returns a successful response + # Not yet ready + Got 3 failures: + + 1.1) Failure/Error: expect(response.status).to eq(200) + + expected: 200 + got: 404 + + (compared using ==) + # ./spec/pending_spec.rb:8:in `block (2 levels) in ' + + 1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json") + expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"} + Diff: + @@ -1,2 +1,2 @@ + -[{"Content-Type"=>"application/json"}] + +"Content-Type" => "text/plain", + # ./spec/pending_spec.rb:9:in `block (2 levels) in ' + + 1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}') + + expected: "{\"message\":\"Success\"}" + got: "Not Found" + + (compared using ==) + # ./spec/pending_spec.rb:10:in `block (2 levels) in ' + """ diff --git a/features/expectation_framework_integration/configure_expectation_framework.feature b/features/expectation_framework_integration/configure_expectation_framework.feature index 6a28436aca..e63d3caf6a 100644 --- a/features/expectation_framework_integration/configure_expectation_framework.feature +++ b/features/expectation_framework_integration/configure_expectation_framework.feature @@ -45,7 +45,8 @@ Feature: configure expectation framework Then the examples should all pass Scenario: Configure test/unit assertions - Given a file named "example_spec.rb" with: + Given rspec-expectations is not installed + And a file named "example_spec.rb" with: """ruby RSpec.configure do |config| config.expect_with :test_unit @@ -72,7 +73,8 @@ Feature: configure expectation framework And the output should contain "3 examples, 1 failure" Scenario: Configure minitest assertions - Given a file named "example_spec.rb" with: + Given rspec-expectations is not installed + And a file named "example_spec.rb" with: """ruby RSpec.configure do |config| config.expect_with :minitest @@ -146,7 +148,8 @@ Feature: configure expectation framework Then the examples should all pass Scenario: Configure test/unit and minitest assertions - Given a file named "example_spec.rb" with: + Given rspec-expectations is not installed + And a file named "example_spec.rb" with: """ruby RSpec.configure do |config| config.expect_with :test_unit, :minitest diff --git a/features/helper_methods/let.feature b/features/helper_methods/let.feature index 0417bd0e2f..7c5d0b0c1d 100644 --- a/features/helper_methods/let.feature +++ b/features/helper_methods/let.feature @@ -7,6 +7,9 @@ Feature: let and let! the method it defines is invoked. You can use `let!` to force the method's invocation before each example. + By default, `let` is threadsafe, but you can configure it not to be + by disabling `config.threadsafe`, which makes `let` perform a bit faster. + Scenario: Use `let` to define memoized helper method Given a file named "let_spec.rb" with: """ruby diff --git a/features/metadata/user_defined.feature b/features/metadata/user_defined.feature index b235035e0b..0383ce9743 100644 --- a/features/metadata/user_defined.feature +++ b/features/metadata/user_defined.feature @@ -8,7 +8,7 @@ Feature: User-defined metadata Metadata defined on an example group is available (and can be overridden) by any sub-group or from any example in that group or a sub-group. - In addition, you can specify metdata using just symbols. Each symbol passed + In addition, you can specify metadata using just symbols. Each symbol passed as an argument to `describe`, `context` or `it` will be a key in the metadata hash, with a corresponding value of `true`. diff --git a/features/mock_framework_integration/use_any_framework.feature b/features/mock_framework_integration/use_any_framework.feature index ce88e1002b..a87e812c9c 100644 --- a/features/mock_framework_integration/use_any_framework.feature +++ b/features/mock_framework_integration/use_any_framework.feature @@ -84,7 +84,7 @@ Feature: mock with an alternative framework require File.expand_path("../expector", __FILE__) RSpec.configure do |config| - config.mock_framework = Expector::RSpecAdapter + config.mock_with Expector::RSpecAdapter end RSpec.describe Expector do diff --git a/features/mock_framework_integration/use_flexmock.feature b/features/mock_framework_integration/use_flexmock.feature index c709a0b7e2..7d1e314620 100644 --- a/features/mock_framework_integration/use_flexmock.feature +++ b/features/mock_framework_integration/use_flexmock.feature @@ -6,7 +6,7 @@ Feature: mock with flexmock Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :flexmock + config.mock_with :flexmock end RSpec.describe "mocking with Flexmock" do @@ -24,7 +24,7 @@ Feature: mock with flexmock Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :flexmock + config.mock_with :flexmock end RSpec.describe "mocking with Flexmock" do @@ -41,7 +41,7 @@ Feature: mock with flexmock Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :flexmock + config.mock_with :flexmock end RSpec.describe "failed message expectation in a pending example" do @@ -60,7 +60,7 @@ Feature: mock with flexmock Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :flexmock + config.mock_with :flexmock end RSpec.describe "passing message expectation in a pending example" do @@ -81,7 +81,7 @@ Feature: mock with flexmock Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :flexmock + config.mock_with :flexmock end RSpec.describe "RSpec.configuration.mock_framework.framework_name" do diff --git a/features/mock_framework_integration/use_mocha.feature b/features/mock_framework_integration/use_mocha.feature index 750db2c1be..1d116c4a6b 100644 --- a/features/mock_framework_integration/use_mocha.feature +++ b/features/mock_framework_integration/use_mocha.feature @@ -6,7 +6,7 @@ Feature: mock with mocha Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :mocha + config.mock_with :mocha end RSpec.describe "mocking with RSpec" do @@ -24,7 +24,7 @@ Feature: mock with mocha Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :mocha + config.mock_with :mocha end RSpec.describe "mocking with RSpec" do @@ -41,7 +41,7 @@ Feature: mock with mocha Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :mocha + config.mock_with :mocha end RSpec.describe "failed message expectation in a pending example" do @@ -60,7 +60,7 @@ Feature: mock with mocha Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :mocha + config.mock_with :mocha end RSpec.describe "passing message expectation in a pending example" do @@ -81,7 +81,7 @@ Feature: mock with mocha Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :mocha + config.mock_with :mocha end RSpec.describe "RSpec.configuration.mock_framework.framework_name" do diff --git a/features/mock_framework_integration/use_rr.feature b/features/mock_framework_integration/use_rr.feature index 29d07ed2b5..7a7a4e57fe 100644 --- a/features/mock_framework_integration/use_rr.feature +++ b/features/mock_framework_integration/use_rr.feature @@ -6,7 +6,7 @@ Feature: mock with rr Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rr + config.mock_with :rr end RSpec.describe "mocking with RSpec" do @@ -24,7 +24,7 @@ Feature: mock with rr Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rr + config.mock_with :rr end RSpec.describe "mocking with RSpec" do @@ -41,7 +41,7 @@ Feature: mock with rr Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rr + config.mock_with :rr end RSpec.describe "failed message expectation in a pending example" do @@ -60,7 +60,7 @@ Feature: mock with rr Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rr + config.mock_with :rr end RSpec.describe "passing message expectation in a pending example" do @@ -81,7 +81,7 @@ Feature: mock with rr Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rr + config.mock_with :rr end RSpec.describe "RSpec.configuration.mock_framework.framework_name" do diff --git a/features/mock_framework_integration/use_rspec.feature b/features/mock_framework_integration/use_rspec.feature index 9089acc76e..4945bdcda4 100644 --- a/features/mock_framework_integration/use_rspec.feature +++ b/features/mock_framework_integration/use_rspec.feature @@ -7,7 +7,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "mocking with RSpec" do @@ -25,7 +25,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "mocking with RSpec" do @@ -42,7 +42,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "failed message expectation in a pending example" do @@ -61,7 +61,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "passing message expectation in a pending example" do @@ -82,7 +82,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "RSpec.configuration.mock_framework.framework_name" do @@ -98,7 +98,7 @@ Feature: mock with rspec Given a file named "example_spec.rb" with: """ruby RSpec.configure do |config| - config.mock_framework = :rspec + config.mock_with :rspec end RSpec.describe "Testing" do diff --git a/features/step_definitions/additional_cli_steps.rb b/features/step_definitions/additional_cli_steps.rb index 5ecfcadea7..4d7da1b465 100644 --- a/features/step_definitions/additional_cli_steps.rb +++ b/features/step_definitions/additional_cli_steps.rb @@ -1,5 +1,7 @@ require 'rspec/core' # to fix annoying "undefined method `configuration' for RSpec:Module (NoMethodError)" +require './spec/support/formatter_support' + Then /^the output should contain all of these:$/ do |table| table.raw.flatten.each do |string| assert_partial_output(string, all_output) @@ -26,6 +28,11 @@ step %q{the exit status should be 0} end +Then /^it should pass with "(.*?)"$/ do |string| + step %Q{the output should contain "#{string}"} + step %q{the exit status should be 0} +end + Then /^the example(?:s)? should(?: all)? fail$/ do step %q{the output should not contain "0 examples"} step %q{the output should not contain "0 failures"} @@ -124,3 +131,107 @@ When /^I create "([^"]*)" with the following content:$/ do |file_name, content| write_file(file_name, content) end + +Given(/^I have run `([^`]*)` once, resulting in "([^"]*)"$/) do |command, output_snippet| + step %Q{I run `#{command}`} + step %Q{the output from "#{command}" should contain "#{output_snippet}"} +end + +When(/^I fix "(.*?)" by replacing "(.*?)" with "(.*?)"$/) do |file_name, original, replacement| + in_current_dir do + contents = File.read(file_name) + expect(contents).to include(original) + fixed = contents.sub(original, replacement) + File.open(file_name, "w") { |f| f.write(fixed) } + end +end + +Then(/^it should fail with "(.*?)"$/) do |snippet| + assert_failing_with(snippet) +end + +Given(/^I have not configured `example_status_persistence_file_path`$/) do + in_current_dir do + return unless File.exist?("spec/spec_helper.rb") + return unless File.read("spec/spec_helper.rb").include?("example_status_persistence_file_path") + File.open("spec/spec_helper.rb", "w") { |f| f.write("") } + end +end + +Given(/^files "(.*?)" through "(.*?)" with an unrelated passing spec in each file$/) do |file1, file2| + index_1 = Integer(file1[/\d+/]) + index_2 = Integer(file2[/\d+/]) + pattern = file1.sub(/\d+/, '%s') + + index_1.upto(index_2) do |index| + write_file(pattern % index, <<-EOS) + RSpec.describe "Spec file #{index}" do + example { } + end + EOS + end +end + +Then(/^bisect should (succeed|fail) with output like:$/) do |succeed, expected_output| + last_process = only_processes.last + expect(last_exit_status).to eq(succeed == "succeed" ? 0 : 1) + + expected = normalize_durations(expected_output) + actual = normalize_durations(last_process.stdout) + + expect(actual.sub(/\n+\Z/, '')).to eq(expected) +end + +When(/^I run `([^`]+)` and abort in the middle with ctrl\-c$/) do |cmd| + set_env('RUBYOPT', ENV['RUBYOPT'] + " -r#{File.expand_path("../../support/send_sigint_during_bisect.rb", __FILE__)}") + step "I run `#{cmd}`" +end + +Then(/^it should fail and list all the failures:$/) do |string| + step %q{the exit status should not be 0} + expect(normalize_failure_output(all_output)).to include(normalize_failure_output(string)) +end + +Then(/^it should pass and list all the pending examples:$/) do |string| + step %q{the exit status should be 0} + expect(normalize_failure_output(all_output)).to include(normalize_failure_output(string)) +end + +Then(/^the output should report "slow before context hook" as the slowest example group$/) do + # These expectations are trying to guard against a regression that introduced + # this output: + # Top 1 slowest example groups: + # slow before context hook + # Inf seconds average (0.00221 seconds / 0 examples) RSpec::ExampleGroups::SlowBeforeContextHook::Nested + # + # Problems: + # - "Inf seconds" + # - 0 examples + # - "Nested" group listed (it should be the outer group) + # - The example group class name is listed (it should be the location) + + expect(all_output).not_to match(/nested/i) + expect(all_output).not_to match(/inf/i) + expect(all_output).not_to match(/\b0 examples/i) + + seconds = '\d+(?:\.\d+)? seconds' + + expect(all_output).to match( + %r{Top 1 slowest example groups?:\n\s+slow before context hook\n\s+#{seconds} average \(#{seconds} / 1 example\) \./spec/example_spec\.rb:1} + ) +end + +module Normalization + def normalize_failure_output(text) + whitespace_normalized = text.lines.map { |line| line.sub(/\s+$/, '').sub(/:in .*$/, '') }.join + + # 1.8.7 and JRuby produce slightly different output for `Hash#fetch` errors, so we + # convert it to the same output here to match our expectation. + whitespace_normalized. + sub("IndexError", "KeyError"). + sub(/key not found.*$/, "key not found") + end +end + +World(Normalization) +World(FormatterSupport) diff --git a/features/step_definitions/core_standalone_steps.rb b/features/step_definitions/core_standalone_steps.rb index fd24c3aa34..f847cb8575 100644 --- a/features/step_definitions/core_standalone_steps.rb +++ b/features/step_definitions/core_standalone_steps.rb @@ -10,3 +10,7 @@ # rspec-expectations from the load path. set_env('REMOVE_OTHER_RSPEC_LIBS_FROM_LOAD_PATH', 'true') end + +Given(/^rspec-expectations is not installed$/) do + step "only rspec-core is installed" +end diff --git a/features/support/env.rb b/features/support/env.rb index 36b8026e6d..e31479aa5d 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -1,6 +1,9 @@ require 'aruba/cucumber' Before do + # Force ids to be printed unquoted for consistency + set_env('SHELL', '/usr/bin/bash') + if RUBY_PLATFORM =~ /java/ || defined?(Rubinius) @aruba_timeout_seconds = 60 else diff --git a/features/support/send_sigint_during_bisect.rb b/features/support/send_sigint_during_bisect.rb new file mode 100644 index 0000000000..9002f73635 --- /dev/null +++ b/features/support/send_sigint_during_bisect.rb @@ -0,0 +1,21 @@ +require 'rspec/core' +RSpec::Support.require_rspec_core "formatters/bisect_progress_formatter" + +module RSpec::Core::Formatters + BisectProgressFormatter = Class.new(remove_const :BisectProgressFormatter) do + RSpec::Core::Formatters.register self + + def bisect_round_finished(notification) + return super unless notification.round == 3 + + Process.kill("INT", Process.pid) + # Process.kill is not a synchronous call, so to ensure the output + # below aborts at a deterministic place, we need to block here. + # The sleep will be interrupted by the signal once the OS sends it. + # For the most part, this is only needed on JRuby, but we saw + # the asynchronous behavior on an MRI 2.0 travis build as well. + sleep 5 + end + end +end + diff --git a/lib/rspec/autorun.rb b/lib/rspec/autorun.rb index 18cc1eddb3..3080cfdd4b 100644 --- a/lib/rspec/autorun.rb +++ b/lib/rspec/autorun.rb @@ -1,2 +1,3 @@ require 'rspec/core' +# Ensure the default config is loaded RSpec::Core::Runner.autorun diff --git a/lib/rspec/core.rb b/lib/rspec/core.rb index 77d93705e8..ccb424c1ff 100644 --- a/lib/rspec/core.rb +++ b/lib/rspec/core.rb @@ -2,8 +2,6 @@ $_rspec_core_load_started_at = Time.now # rubocop:enable Style/GlobalVars -require 'rbconfig' - require "rspec/support" RSpec::Support.require_rspec_support "caller_filter" @@ -13,6 +11,7 @@ version warnings + set flat_map filter_manager dsl @@ -82,12 +81,9 @@ def self.clear_examples # @see RSpec.configure # @see Core::Configuration def self.configuration - @configuration ||= begin - config = RSpec::Core::Configuration.new - config.expose_dsl_globally = true - config - end + @configuration ||= RSpec::Core::Configuration.new end + configuration.expose_dsl_globally = true # Yields the global configuration to a block. # @yield [Configuration] global configuration @@ -123,20 +119,13 @@ def self.configure # end # def self.current_example - thread_local_metadata[:current_example] + RSpec::Support.thread_local_data[:current_example] end # Set the current example being executed. # @api private def self.current_example=(example) - thread_local_metadata[:current_example] = example - end - - # @private - # A single thread local variable so we don't excessively pollute that - # namespace. - def self.thread_local_metadata - Thread.current[:_rspec] ||= { :shared_example_group_inclusions => [] } + RSpec::Support.thread_local_data[:current_example] = example end # @private @@ -147,6 +136,9 @@ def self.world # Namespace for the rspec-core code. module Core + autoload :ExampleStatusPersister, "rspec/core/example_status_persister" + autoload :Profiler, "rspec/core/profiler" + # @private # This avoids issues with reporting time caused by examples that # change the value/meaning of Time.now without properly restoring diff --git a/lib/rspec/core/backport_random.rb b/lib/rspec/core/backport_random.rb deleted file mode 100644 index 1b8afaf56a..0000000000 --- a/lib/rspec/core/backport_random.rb +++ /dev/null @@ -1,339 +0,0 @@ -module RSpec - module Core - # @private - # - # Methods used internally by the backports. - # - # This code was (mostly) ported from the backports gem found at - # https://github.com/marcandre/backports which is subject to this license: - # - # ========================================================================= - # - # Copyright (c) 2009 Marc-Andre Lafortune - # - # Permission is hereby granted, free of charge, to any person obtaining - # a copy of this software and associated documentation files (the - # "Software"), to deal in the Software without restriction, including - # without limitation the rights to use, copy, modify, merge, publish, - # distribute, sublicense, and/or sell copies of the Software, and to - # permit persons to whom the Software is furnished to do so, subject to - # the following conditions: - # - # The above copyright notice and this permission notice shall be - # included in all copies or substantial portions of the Software. - # - # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - # - # ========================================================================= - # - # The goal is to provide a random number generator in Ruby versions that do - # not have one. This was added to support localization of random spec - # ordering. - # - # These were in multiple files in backports, but merged into one here. - module Backports - # Helper method to coerce a value into a specific class. - # Raises a TypeError if the coercion fails or the returned value - # is not of the right class. - # (from Rubinius) - def self.coerce_to(obj, cls, meth) - return obj if obj.kind_of?(cls) - - begin - ret = obj.__send__(meth) - rescue Exception => e - raise TypeError, "Coercion error: #{obj.inspect}.#{meth} => #{cls} failed:\n" \ - "(#{e.message})" - end - raise TypeError, "Coercion error: obj.#{meth} did NOT return a #{cls} (was #{ret.class})" unless ret.kind_of? cls - ret - end - - # @private - def self.coerce_to_int(obj) - coerce_to(obj, Integer, :to_int) - end - - # Used internally to make it easy to deal with optional arguments. - # (from Rubinius) - Undefined = Object.new - - # @private - class Random - # @private - # An implementation of Mersenne Twister MT19937 in Ruby. - class MT19937 - STATE_SIZE = 624 - LAST_STATE = STATE_SIZE - 1 - PAD_32_BITS = 0xffffffff - - # See seed= - def initialize(seed) - self.seed = seed - end - - LAST_31_BITS = 0x7fffffff - OFFSET = 397 - - # Generates a completely new state out of the previous one. - def next_state - STATE_SIZE.times do |i| - mix = @state[i] & 0x80000000 | @state[i+1 - STATE_SIZE] & 0x7fffffff - @state[i] = @state[i+OFFSET - STATE_SIZE] ^ (mix >> 1) - @state[i] ^= 0x9908b0df if mix.odd? - end - @last_read = -1 - end - - # Seed must be either an Integer (only the first 32 bits will be used) - # or an Array of Integers (of which only the first 32 bits will be - # used). - # - # No conversion or type checking is done at this level. - def seed=(seed) - case seed - when Integer - @state = Array.new(STATE_SIZE) - @state[0] = seed & PAD_32_BITS - (1..LAST_STATE).each do |i| - @state[i] = (1812433253 * (@state[i-1] ^ @state[i-1]>>30) + i)& PAD_32_BITS - end - @last_read = LAST_STATE - when Array - self.seed = 19650218 - i=1 - j=0 - [STATE_SIZE, seed.size].max.times do - @state[i] = (@state[i] ^ (@state[i-1] ^ @state[i-1]>>30) * 1664525) + j + seed[j] & PAD_32_BITS - if (i+=1) >= STATE_SIZE - @state[0] = @state[-1] - i = 1 - end - j = 0 if (j+=1) >= seed.size - end - (STATE_SIZE-1).times do - @state[i] = (@state[i] ^ (@state[i-1] ^ @state[i-1]>>30) * 1566083941) - i & PAD_32_BITS - if (i+=1) >= STATE_SIZE - @state[0] = @state[-1] - i = 1 - end - end - @state[0] = 0x80000000 - else - raise ArgumentError, "Seed must be an Integer or an Array" - end - end - - # Returns a random Integer from the range 0 ... (1 << 32). - def random_32_bits - next_state if @last_read >= LAST_STATE - @last_read += 1 - y = @state[@last_read] - # Tempering - y ^= (y >> 11) - y ^= (y << 7) & 0x9d2c5680 - y ^= (y << 15) & 0xefc60000 - y ^= (y >> 18) - end - - # Supplement the MT19937 class with methods to do - # conversions the same way as MRI. - # No argument checking is done here either. - - FLOAT_FACTOR = 1.0/9007199254740992.0 - # Generates a random number on [0, 1) with 53-bit resolution. - def random_float - ((random_32_bits >> 5) * 67108864.0 + (random_32_bits >> 6)) * FLOAT_FACTOR; - end - - # Returns an integer within 0...upto. - def random_integer(upto) - n = upto - 1 - nb_full_32 = 0 - while n > PAD_32_BITS - n >>= 32 - nb_full_32 += 1 - end - mask = mask_32_bits(n) - begin - rand = random_32_bits & mask - nb_full_32.times do - rand <<= 32 - rand |= random_32_bits - end - end until rand < upto - rand - end - - def random_bytes(nb) - nb_32_bits = (nb + 3) / 4 - random = nb_32_bits.times.map { random_32_bits } - random.pack("L" * nb_32_bits)[0, nb] - end - - def state_as_bignum - b = 0 - @state.each_with_index do |val, i| - b |= val << (32 * i) - end - b - end - - def left # It's actually the number of words left + 1, as per MRI... - MT19937::STATE_SIZE - @last_read - end - - def marshal_dump - [state_as_bignum, left] - end - - def marshal_load(ary) - b, left = ary - @last_read = MT19937::STATE_SIZE - left - @state = Array.new(STATE_SIZE) - STATE_SIZE.times do |i| - @state[i] = b & PAD_32_BITS - b >>= 32 - end - end - - # Convert an Integer seed of arbitrary size to either a single 32 bit - # integer, or an Array of 32 bit integers. - def self.convert_seed(seed) - seed = seed.abs - long_values = [] - begin - long_values << (seed & PAD_32_BITS) - seed >>= 32 - end until seed == 0 - - # Done to allow any kind of sequence of integers. - long_values.pop if long_values[-1] == 1 && long_values.size > 1 - - long_values.size > 1 ? long_values : long_values.first - end - - def self.[](seed) - new(convert_seed(seed)) - end - - private - - MASK_BY = [1,2,4,8,16] - def mask_32_bits(n) - MASK_BY.each do |shift| - n |= n >> shift - end - n - end - end - - # @private - # Implementation corresponding to the actual Random class of Ruby - # The actual random generator (mersenne twister) is in MT19937. - # Ruby specific conversions are handled in bits_and_bytes. - # The high level stuff (argument checking) is done here. - module Implementation - attr_reader :seed - - def initialize(seed = 0) - super() - seed_rand seed - end - - def seed_rand(new_seed = 0) - new_seed = Backports.coerce_to_int(new_seed) - @seed = nil unless defined?(@seed) - old, @seed = @seed, new_seed.nonzero? || Random.new_seed - @mt = MT19937[ @seed ] - old - end - - def rand(limit = Backports::Undefined) - case limit - when Backports::Undefined - @mt.random_float - when Float - limit * @mt.random_float unless limit <= 0 - when Range - _rand_range(limit) - else - limit = Backports.coerce_to_int(limit) - @mt.random_integer(limit) unless limit <= 0 - end || raise(ArgumentError, "invalid argument #{limit}") - end - - def bytes(nb) - nb = Backports.coerce_to_int(nb) - raise ArgumentError, "negative size" if nb < 0 - @mt.random_bytes(nb) - end - - def ==(other) - other.is_a?(Random) && - seed == other.seed && - left == other.send(:left) && - state == other.send(:state) - end - - def marshal_dump - @mt.marshal_dump << @seed - end - - def marshal_load(ary) - @seed = ary.pop - @mt = MT19937.allocate - @mt.marshal_load(ary) - end - - private - - def state - @mt.state_as_bignum - end - - def left - @mt.left - end - - def _rand_range(limit) - range = limit.end - limit.begin - if (!range.is_a?(Float)) && range.respond_to?(:to_int) && range = Backports.coerce_to_int(range) - range += 1 unless limit.exclude_end? - limit.begin + @mt.random_integer(range) unless range <= 0 - elsif range = Backports.coerce_to(range, Float, :to_f) - if range < 0 - nil - elsif limit.exclude_end? - limit.begin + @mt.random_float * range unless range <= 0 - else - # cheat a bit... this will reduce the nb of random bits - loop do - r = @mt.random_float * range * 1.0001 - break limit.begin + r unless r > range - end - end - end - end - end - - def self.new_seed - (2 ** 62) + Kernel.rand(2 ** 62) - end - end - - class Random - include Implementation - class << self - include Implementation - end - end - end - end -end diff --git a/lib/rspec/core/backtrace_formatter.rb b/lib/rspec/core/backtrace_formatter.rb index b1dff2f1c7..74617ec258 100644 --- a/lib/rspec/core/backtrace_formatter.rb +++ b/lib/rspec/core/backtrace_formatter.rb @@ -31,7 +31,7 @@ def filter_gem(gem_name) end def format_backtrace(backtrace, options={}) - return backtrace if options[:full_backtrace] + return backtrace if options[:full_backtrace] || backtrace.empty? backtrace.map { |l| backtrace_line(l) }.compact. tap do |filtered| @@ -47,8 +47,6 @@ def format_backtrace(backtrace, options={}) def backtrace_line(line) Metadata.relative_path(line) unless exclude?(line) - rescue SecurityError - nil end def exclude?(line) diff --git a/lib/rspec/core/bisect/coordinator.rb b/lib/rspec/core/bisect/coordinator.rb new file mode 100644 index 0000000000..16448c1809 --- /dev/null +++ b/lib/rspec/core/bisect/coordinator.rb @@ -0,0 +1,66 @@ +RSpec::Support.require_rspec_core "bisect/server" +RSpec::Support.require_rspec_core "bisect/runner" +RSpec::Support.require_rspec_core "bisect/example_minimizer" +RSpec::Support.require_rspec_core "formatters/bisect_progress_formatter" + +module RSpec + module Core + module Bisect + # @private + # The main entry point into the bisect logic. Coordinates among: + # - Bisect::Server: Receives suite results. + # - Bisect::Runner: Runs a set of examples and directs the results + # to the server. + # - Bisect::ExampleMinimizer: Contains the core bisect logic. + # - Formatters::BisectProgressFormatter: provides progress updates + # to the user. + class Coordinator + def self.bisect_with(original_cli_args, configuration, formatter) + new(original_cli_args, configuration, formatter).bisect + end + + def initialize(original_cli_args, configuration, formatter) + @original_cli_args = original_cli_args + @configuration = configuration + @formatter = formatter + end + + def bisect + @configuration.add_formatter @formatter + + reporter.close_after do + repro = Server.run do |server| + runner = Runner.new(server, @original_cli_args) + minimizer = ExampleMinimizer.new(runner, reporter) + + gracefully_abort_on_sigint(minimizer) + minimizer.find_minimal_repro + minimizer.repro_command_for_currently_needed_ids + end + + reporter.publish(:bisect_repro_command, :repro => repro) + end + + true + rescue BisectFailedError => e + reporter.publish(:bisect_failed, :failure_explanation => e.message) + false + end + + private + + def reporter + @configuration.reporter + end + + def gracefully_abort_on_sigint(minimizer) + trap('INT') do + repro = minimizer.repro_command_for_currently_needed_ids + reporter.publish(:bisect_aborted, :repro => repro) + exit(1) + end + end + end + end + end +end diff --git a/lib/rspec/core/bisect/example_minimizer.rb b/lib/rspec/core/bisect/example_minimizer.rb new file mode 100644 index 0000000000..f8a6022daf --- /dev/null +++ b/lib/rspec/core/bisect/example_minimizer.rb @@ -0,0 +1,130 @@ +RSpec::Support.require_rspec_core "bisect/subset_enumerator" + +module RSpec + module Core + module Bisect + # @private + # Contains the core bisect logic. Searches for examples we can ignore by + # repeatedly running different subsets of the suite. + class ExampleMinimizer + attr_reader :runner, :reporter, :all_example_ids, :failed_example_ids + attr_accessor :remaining_ids + + def initialize(runner, reporter) + @runner = runner + @reporter = reporter + end + + def find_minimal_repro + prep + + self.remaining_ids = non_failing_example_ids + + each_bisect_round do |subsets| + ids_to_ignore = subsets.find do |ids| + get_expected_failures_for?(remaining_ids - ids) + end + + next :done unless ids_to_ignore + + self.remaining_ids -= ids_to_ignore + notify(:bisect_ignoring_ids, :ids_to_ignore => ids_to_ignore, :remaining_ids => remaining_ids) + end + + currently_needed_ids + end + + def currently_needed_ids + remaining_ids + failed_example_ids + end + + def repro_command_for_currently_needed_ids + return runner.repro_command_from(currently_needed_ids) if remaining_ids + "(Not yet enough information to provide any repro command)" + end + + private + + def prep + notify(:bisect_starting, :original_cli_args => runner.original_cli_args) + + _, duration = track_duration do + original_results = runner.original_results + @all_example_ids = original_results.all_example_ids + @failed_example_ids = original_results.failed_example_ids + end + + if @failed_example_ids.empty? + raise BisectFailedError, "\n\nNo failures found. Bisect only works " \ + "in the presence of one or more failing examples." + else + notify(:bisect_original_run_complete, :failed_example_ids => failed_example_ids, + :non_failing_example_ids => non_failing_example_ids, + :duration => duration) + end + end + + def non_failing_example_ids + @non_failing_example_ids ||= all_example_ids - failed_example_ids + end + + def get_expected_failures_for?(ids) + ids_to_run = ids + failed_example_ids + notify(:bisect_individual_run_start, :command => runner.repro_command_from(ids_to_run)) + + results, duration = track_duration { runner.run(ids_to_run) } + notify(:bisect_individual_run_complete, :duration => duration, :results => results) + + abort_if_ordering_inconsistent(results) + (failed_example_ids & results.failed_example_ids) == failed_example_ids + end + + INFINITY = (1.0 / 0) # 1.8.7 doesn't define Float::INFINITY so we define our own... + + def each_bisect_round(&block) + last_round, duration = track_duration do + 1.upto(INFINITY) do |round| + break if :done == bisect_round(round, &block) + end + end + + notify(:bisect_complete, :round => last_round, :duration => duration, + :original_non_failing_count => non_failing_example_ids.size, + :remaining_count => remaining_ids.size) + end + + def bisect_round(round) + value, duration = track_duration do + subsets = SubsetEnumerator.new(remaining_ids) + notify(:bisect_round_started, :round => round, + :subset_size => subsets.subset_size, + :remaining_count => remaining_ids.size) + + yield subsets + end + + notify(:bisect_round_finished, :duration => duration, :round => round) + value + end + + def track_duration + start = ::RSpec::Core::Time.now + [yield, ::RSpec::Core::Time.now - start] + end + + def abort_if_ordering_inconsistent(results) + expected_order = all_example_ids & results.all_example_ids + return if expected_order == results.all_example_ids + + raise BisectFailedError, "\n\nThe example ordering is inconsistent. " \ + "`--bisect` relies upon consistent ordering (e.g. by passing " \ + "`--seed` if you're using random ordering) to work properly." + end + + def notify(*args) + reporter.publish(*args) + end + end + end + end +end diff --git a/lib/rspec/core/bisect/runner.rb b/lib/rspec/core/bisect/runner.rb new file mode 100644 index 0000000000..a9d07dcd69 --- /dev/null +++ b/lib/rspec/core/bisect/runner.rb @@ -0,0 +1,139 @@ +RSpec::Support.require_rspec_core "shell_escape" +require 'open3' + +module RSpec + module Core + module Bisect + # Provides an API to run the suite for a set of locations, using + # the given bisect server to capture the results. + # @private + class Runner + attr_reader :original_cli_args + + def initialize(server, original_cli_args) + @server = server + @original_cli_args = original_cli_args.reject { |arg| arg.start_with?("--bisect") } + end + + def run(locations) + run_locations(locations, original_results.failed_example_ids) + end + + def command_for(locations) + parts = [] + + parts << RUBY << load_path + parts << open3_safe_escape(RSpec::Core.path_to_executable) + + parts << "--format" << "bisect" + parts << "--drb-port" << @server.drb_port + parts.concat reusable_cli_options + parts.concat locations.map { |l| open3_safe_escape(l) } + + parts.join(" ") + end + + def repro_command_from(locations) + parts = [] + + parts << "rspec" + parts.concat Formatters::Helpers.organize_ids(locations) + parts.concat original_cli_args_without_locations + + parts.join(" ") + end + + def original_results + @original_results ||= run_locations(original_locations) + end + + private + + include RSpec::Core::ShellEscape + # On JRuby, Open3.popen3 does not handle shellescaped args properly: + # https://github.com/jruby/jruby/issues/2767 + if RSpec::Support::Ruby.jruby? + # :nocov: + alias open3_safe_escape quote + # :nocov: + else + alias open3_safe_escape escape + end + + def run_locations(locations, *capture_args) + @server.capture_run_results(*capture_args) do + run_command command_for(locations) + end + end + + # `Open3.capture2e` does not work on JRuby: + # https://github.com/jruby/jruby/issues/2766 + if Open3.respond_to?(:capture2e) && !RSpec::Support::Ruby.jruby? + def run_command(cmd) + Open3.capture2e(cmd).first + end + else # for 1.8.7 + # :nocov: + def run_command(cmd) + out = err = nil + + Open3.popen3(cmd) do |_, stdout, stderr| + # Reading the streams blocks until the process is complete + out = stdout.read + err = stderr.read + end + + "Stdout:\n#{out}\n\nStderr:\n#{err}" + end + # :nocov: + end + + def reusable_cli_options + @reusable_cli_options ||= begin + opts = original_cli_args_without_locations + + if (port = parsed_original_cli_options[:drb_port]) + opts -= %W[ --drb-port #{port} ] + end + + parsed_original_cli_options.fetch(:formatters) { [] }.each do |(name, out)| + opts -= %W[ --format #{name} -f -f#{name} ] + opts -= %W[ --out #{out} -o -o#{out} ] + end + + opts + end + end + + def original_cli_args_without_locations + @original_cli_args_without_locations ||= begin + files_or_dirs = parsed_original_cli_options.fetch(:files_or_directories_to_run) + @original_cli_args - files_or_dirs + end + end + + def parsed_original_cli_options + @parsed_original_cli_options ||= Parser.parse(@original_cli_args) + end + + def original_locations + parsed_original_cli_options.fetch(:files_or_directories_to_run) + end + + def load_path + @load_path ||= "-I#{$LOAD_PATH.map { |p| open3_safe_escape(p) }.join(':')}" + end + + # Path to the currently running Ruby executable, borrowed from Rake: + # https://github.com/ruby/rake/blob/v10.4.2/lib/rake/file_utils.rb#L8-L12 + # Note that we skip `ENV['RUBY']` because we don't have to deal with running + # RSpec from within a MRI source repository: + # https://github.com/ruby/rake/commit/968682759b3b65e42748cd2befb2ff3e982272d9 + RUBY = File.join( + RbConfig::CONFIG['bindir'], + RbConfig::CONFIG['ruby_install_name'] + RbConfig::CONFIG['EXEEXT']). + sub(/.*\s.*/m, '"\&"') + end + end + end +end diff --git a/lib/rspec/core/bisect/server.rb b/lib/rspec/core/bisect/server.rb new file mode 100644 index 0000000000..35fe97d4e4 --- /dev/null +++ b/lib/rspec/core/bisect/server.rb @@ -0,0 +1,61 @@ +require 'drb/drb' +require 'drb/acl' + +module RSpec + module Core + # @private + module Bisect + # @private + BisectFailedError = Class.new(StandardError) + + # @private + # A DRb server that receives run results from a separate RSpec process + # started by the bisect process. + class Server + def self.run + server = new + server.start + yield server + ensure + server.stop + end + + def capture_run_results(expected_failures=[]) + self.expected_failures = expected_failures + self.latest_run_results = nil + run_output = yield + latest_run_results || raise_bisect_failed(run_output) + end + + def start + # Only allow remote DRb requests from this machine. + DRb.install_acl ACL.new(%w[ deny all allow localhost allow 127.0.0.1 ]) + + # We pass `nil` as the first arg to allow it to pick a DRb port. + @drb = DRb.start_service(nil, self) + end + + def stop + @drb.stop_service + end + + def drb_port + @drb_port ||= Integer(@drb.uri[/\d+$/]) + end + + # Fetched via DRb by the BisectFormatter to determine when to abort. + attr_accessor :expected_failures + + # Set via DRb by the BisectFormatter with the results of the run. + attr_accessor :latest_run_results + + private + + def raise_bisect_failed(run_output) + raise BisectFailedError, "Failed to get results from the spec " \ + "run. Spec run output:\n\n#{run_output}" + end + end + end + end +end diff --git a/lib/rspec/core/bisect/subset_enumerator.rb b/lib/rspec/core/bisect/subset_enumerator.rb new file mode 100644 index 0000000000..7dc52cf88d --- /dev/null +++ b/lib/rspec/core/bisect/subset_enumerator.rb @@ -0,0 +1,39 @@ +module RSpec + module Core + module Bisect + # Enumerates each subset of the given list of ids that is half the + # size of the total list, so that hopefully we can discard half the + # list each repeatedly in order to our minimal repro case. + # @private + class SubsetEnumerator + include Enumerable + + def initialize(ids) + @ids = ids + end + + def subset_size + @subset_size ||= (@ids.size / 2.0).ceil + end + + def each + yielded = Set.new + slice_size = subset_size + combo_count = 1 + + while slice_size > 0 + @ids.each_slice(slice_size).to_a.combination(combo_count) do |combos| + subset = combos.flatten + next if yielded.include?(subset) + yield subset + yielded << subset + end + + slice_size /= 2 + combo_count *= 2 + end + end + end + end + end +end diff --git a/lib/rspec/core/configuration.rb b/lib/rspec/core/configuration.rb index f5dd8f9543..2834d75797 100644 --- a/lib/rspec/core/configuration.rb +++ b/lib/rspec/core/configuration.rb @@ -154,6 +154,33 @@ def deprecation_stream=(value) end end + # @macro define_reader + # The file path to use for persisting example statuses. Necessary for the + # `--only-failures` and `--next-failures` CLI options. + # + # @overload example_status_persistence_file_path + # @return [String] the file path + # @overload example_status_persistence_file_path=(value) + # @param value [String] the file path + define_reader :example_status_persistence_file_path + + # Sets the file path to use for persisting example statuses. Necessary for the + # `--only-failures` and `--next-failures` CLI options. + def example_status_persistence_file_path=(value) + @example_status_persistence_file_path = value + clear_values_derived_from_example_status_persistence_file_path + end + + # @macro define_reader + # Indicates if the `--only-failures` (or `--next-failure`) flag is being used. + define_reader :only_failures + alias_method :only_failures?, :only_failures + + # @private + def only_failures_but_not_configured? + only_failures? && !example_status_persistence_file_path + end + # @macro add_setting # Clean up and exit after the first failure (default: `false`). add_setting :fail_fast @@ -281,6 +308,11 @@ def treat_symbols_as_metadata_keys_with_true_values=(_value) # Record the start time of the spec suite to measure load time. add_setting :start_time + # @macro add_setting + # Use threadsafe options where available. + # Currently this will place a mutex around memoized values such as let blocks. + add_setting :threadsafe + # @private add_setting :tty # @private @@ -334,6 +366,9 @@ def initialize @requires = [] @libs = [] @derived_metadata_blocks = FilterableItemRepository::QueryOptimized.new(:any?) + @threadsafe = true + + define_built_in_hooks end # @private @@ -342,6 +377,9 @@ def initialize def force(hash) ordering_manager.force(hash) @preferred_options.merge!(hash) + + return unless hash.key?(:example_status_persistence_file_path) + clear_values_derived_from_example_status_persistence_file_path end # @private @@ -600,6 +638,13 @@ def expect_with(*frameworks) framework when :rspec require 'rspec/expectations' + + # Tag this exception class so our exception formatting logic knows + # that it satisfies the `MultipleExceptionError` interface. + ::RSpec::Expectations::MultipleExpectationsNotMetError.__send__( + :include, MultipleExceptionError::InterfaceTag + ) + ::RSpec::Matchers when :test_unit require 'rspec/core/test_unit_assertions_adapter' @@ -792,7 +837,11 @@ def profile_examples # @private def files_or_directories_to_run=(*files) files = files.flatten - files << default_path if (command == 'rspec' || Runner.running_in_drb?) && default_path && files.empty? + + if (command == 'rspec' || Runner.running_in_drb?) && default_path && files.empty? + files << default_path + end + @files_or_directories_to_run = files @files_to_run = nil end @@ -803,6 +852,40 @@ def files_to_run @files_to_run ||= get_files_to_run(@files_or_directories_to_run) end + # @private + def last_run_statuses + @last_run_statuses ||= Hash.new(UNKNOWN_STATUS).tap do |statuses| + if (path = example_status_persistence_file_path) + begin + ExampleStatusPersister.load_from(path).inject(statuses) do |hash, example| + hash[example.fetch(:example_id)] = example.fetch(:status) + hash + end + rescue SystemCallError => e + RSpec.warning "Could not read from #{path.inspect} (configured as " \ + "`config.example_status_persistence_file_path`) due " \ + "to a system error: #{e.inspect}. Please check that " \ + "the config option is set to an accessible, valid " \ + "file path", :call_site => nil + end + end + end + end + + # @private + UNKNOWN_STATUS = "unknown".freeze + + # @private + FAILED_STATUS = "failed".freeze + + # @private + def spec_files_with_failures + @spec_files_with_failures ||= last_run_statuses.inject(Set.new) do |files, (id, status)| + files << id.split(ON_SQUARE_BRACKETS).first if status == FAILED_STATUS + files + end.to_a + end + # Creates a method that delegates to `example` including the submitted # `args`. Used internally to add variants of `example` like `pending`: # @param name [String] example name alias @@ -1060,6 +1143,7 @@ def exclusion_filter def include(mod, *filters) meta = Metadata.build_hash_from(filters, :warn_about_example_group_filtering) @include_modules.append(mod, meta) + configure_existing_groups(mod, meta, :safe_include) end # Tells RSpec to extend example groups with `mod`. Methods defined in @@ -1095,6 +1179,7 @@ def include(mod, *filters) def extend(mod, *filters) meta = Metadata.build_hash_from(filters, :warn_about_example_group_filtering) @extend_modules.append(mod, meta) + configure_existing_groups(mod, meta, :safe_extend) end if RSpec::Support::RubyFeatures.module_prepends_supported? @@ -1133,6 +1218,7 @@ def extend(mod, *filters) def prepend(mod, *filters) meta = Metadata.build_hash_from(filters, :warn_about_example_group_filtering) @prepend_modules.append(mod, meta) + configure_existing_groups(mod, meta, :safe_prepend) end end @@ -1153,17 +1239,30 @@ def configure_group_with(group, module_list, application_method) end end + # @private + def configure_existing_groups(mod, meta, application_method) + RSpec.world.all_example_groups.each do |group| + next unless meta.empty? || MetadataFilter.apply?(:any?, meta, group.metadata) + __send__(application_method, mod, group) + end + end + # @private # # Used internally to extend the singleton class of a single example's # example group instance with modules using `include` and/or `extend`. def configure_example(example) + singleton_group = example.example_group_instance.singleton_class + # We replace the metadata so that SharedExampleGroupModule#included # has access to the example's metadata[:location]. - example.example_group_instance.singleton_class.with_replaced_metadata(example.metadata) do - @include_modules.items_for(example.metadata).each do |mod| + singleton_group.with_replaced_metadata(example.metadata) do + modules = @include_modules.items_for(example.metadata) + modules.each do |mod| safe_include(mod, example.example_group_instance.singleton_class) end + + MemoizedHelpers.define_helpers_on(singleton_group) unless modules.empty? end end @@ -1193,7 +1292,8 @@ def safe_include(mod, host) def safe_extend(mod, host) host.extend(mod) unless host.singleton_class < mod end - else + else # for 1.8.7 + # :nocov: # @private def safe_include(mod, host) host.__send__(:include, mod) unless host.included_modules.include?(mod) @@ -1203,6 +1303,7 @@ def safe_include(mod, host) def safe_extend(mod, host) host.extend(mod) unless (class << host; self; end).included_modules.include?(mod) end + # :nocov: end # @private @@ -1560,10 +1661,15 @@ def run_hooks_with(hooks, hook_context) end def get_files_to_run(paths) - FlatMap.flat_map(paths_to_check(paths)) do |path| + files = FlatMap.flat_map(paths_to_check(paths)) do |path| path = path.gsub(File::ALT_SEPARATOR, File::SEPARATOR) if File::ALT_SEPARATOR File.directory?(path) ? gather_directories(path) : extract_location(path) end.sort.uniq + + return files unless only_failures? + relative_files = files.map { |f| Metadata.relative_path(File.expand_path f) } + intersection = (relative_files & spec_files_with_failures.to_a) + intersection.empty? ? files : intersection end def paths_to_check(paths) @@ -1592,6 +1698,7 @@ def file_glob_from(path, pattern) end if RSpec::Support::OS.windows? + # :nocov: def absolute_pattern?(pattern) pattern =~ /\A[A-Z]:\\/ || windows_absolute_network_path?(pattern) end @@ -1600,12 +1707,16 @@ def windows_absolute_network_path?(pattern) return false unless ::File::ALT_SEPARATOR pattern.start_with?(::File::ALT_SEPARATOR + ::File::ALT_SEPARATOR) end + # :nocov: else def absolute_pattern?(pattern) pattern.start_with?(File::Separator) end end + # @private + ON_SQUARE_BRACKETS = /[\[\]]/ + def extract_location(path) match = /^(.*?)((?:\:\d+)+)$/.match(path) @@ -1613,6 +1724,9 @@ def extract_location(path) captures = match.captures path, lines = captures[0], captures[1][1..-1].split(":").map { |n| n.to_i } filter_manager.add_location path, lines + else + path, scoped_ids = path.split(ON_SQUARE_BRACKETS) + filter_manager.add_ids(path, scoped_ids.split(/\s*,\s*/)) if scoped_ids end return [] if path == default_path @@ -1627,6 +1741,16 @@ def value_for(key) @preferred_options.fetch(key) { yield } end + def define_built_in_hooks + around(:example, :aggregate_failures => true) do |procsy| + begin + aggregate_failures(nil, :hide_backtrace => true, &procsy) + rescue Exception => exception + procsy.example.set_aggregate_failures_exception(exception) + end + end + end + def assert_no_example_groups_defined(config_option) return unless RSpec.world.example_groups.any? @@ -1672,6 +1796,11 @@ def update_pattern_attr(name, value) instance_variable_set(:"@#{name}", value) @files_to_run = nil end + + def clear_values_derived_from_example_status_persistence_file_path + @last_run_statuses = nil + @spec_files_with_failures = nil + end end # rubocop:enable Style/ClassLength end diff --git a/lib/rspec/core/configuration_options.rb b/lib/rspec/core/configuration_options.rb index 36b9f5d05b..86dee41bad 100644 --- a/lib/rspec/core/configuration_options.rb +++ b/lib/rspec/core/configuration_options.rb @@ -1,6 +1,5 @@ require 'erb' require 'shellwords' -require 'set' module RSpec module Core @@ -53,12 +52,12 @@ def organize_options end end - UNFORCED_OPTIONS = [ + UNFORCED_OPTIONS = Set.new([ :requires, :profile, :drb, :libs, :files_or_directories_to_run, :full_description, :full_backtrace, :tty - ].to_set + ]) - UNPROCESSABLE_OPTIONS = [:formatters].to_set + UNPROCESSABLE_OPTIONS = Set.new([:formatters]) def force?(key) !UNFORCED_OPTIONS.include?(key) @@ -82,7 +81,7 @@ def order(keys) # `files_or_directories_to_run` uses `default_path` so it must be # set before it. - :default_path, + :default_path, :only_failures, # These must be set before `requires` to support checking # `config.files_to_run` from within `spec_helper.rb` when a @@ -120,11 +119,16 @@ def file_options end def env_options - ENV["SPEC_OPTS"] ? Parser.parse(Shellwords.split(ENV["SPEC_OPTS"])) : {} + return {} unless ENV['SPEC_OPTS'] + + parse_args_ignoring_files_or_dirs_to_run( + Shellwords.split(ENV["SPEC_OPTS"]), + "ENV['SPEC_OPTS']" + ) end def command_line_options - @command_line_options ||= Parser.parse(@args).merge :files_or_directories_to_run => @args + @command_line_options ||= Parser.parse(@args) end def custom_options @@ -144,7 +148,14 @@ def global_options end def options_from(path) - Parser.parse(args_from_options_file(path)) + args = args_from_options_file(path) + parse_args_ignoring_files_or_dirs_to_run(args, path) + end + + def parse_args_ignoring_files_or_dirs_to_run(args, source) + options = Parser.parse(args, source) + options.delete(:files_or_directories_to_run) + options end def args_from_options_file(path) @@ -162,11 +173,11 @@ def custom_options_file end def project_options_file - ".rspec" + "./.rspec" end def local_options_file - ".rspec-local" + "./.rspec-local" end def global_options_file diff --git a/lib/rspec/core/example.rb b/lib/rspec/core/example.rb index 8d02d6ef72..dc820495d4 100644 --- a/lib/rspec/core/example.rb +++ b/lib/rspec/core/example.rb @@ -92,15 +92,32 @@ def inspect_output inspect_output end - # Returns the argument that can be passed to the `rspec` command to rerun this example. - def rerun_argument - loaded_spec_files = RSpec.configuration.loaded_spec_files + # Returns the location-based argument that can be passed to the `rspec` command to rerun this example. + def location_rerun_argument + @location_rerun_argument ||= begin + loaded_spec_files = RSpec.configuration.loaded_spec_files - Metadata.ascending(metadata) do |meta| - return meta[:location] if loaded_spec_files.include?(meta[:absolute_file_path]) + Metadata.ascending(metadata) do |meta| + return meta[:location] if loaded_spec_files.include?(meta[:absolute_file_path]) + end end end + # Returns the location-based argument that can be passed to the `rspec` command to rerun this example. + # + # @deprecated Use {#location_rerun_argument} instead. + # @note If there are multiple examples identified by this location, they will use {#id} + # to rerun instead, but this method will still return the location (that's why it is deprecated!). + def rerun_argument + location_rerun_argument + end + + # @return [String] the unique id of this example. Pass + # this at the command line to re-run this exact example. + def id + @id ||= Metadata.id_from(metadata) + end + # @attr_reader # # Returns the first exception raised in the context of running this @@ -138,13 +155,24 @@ def initialize(example_group_class, description, user_metadata, example_block=ni @example_block = example_block @metadata = Metadata::ExampleHash.create( - @example_group_class.metadata, user_metadata, description, example_block + @example_group_class.metadata, user_metadata, + example_group_class.method(:next_runnable_index_for), + description, example_block ) + # This should perhaps be done in `Metadata::ExampleHash.create`, + # but the logic there has no knowledge of `RSpec.world` and we + # want to keep it that way. It's easier to just assign it here. + @metadata[:last_run_status] = RSpec.configuration.last_run_statuses[id] + @example_group_instance = @exception = nil @clock = RSpec::Core::Time + @reporter = RSpec::Core::NullReporter end + # @return [RSpec::Core::Reporter] the current reporter for the example + attr_reader :reporter + # Returns the example group class that provides the context for running # this example. def example_group @@ -160,6 +188,7 @@ def example_group # @param example_group_instance the instance of an ExampleGroup subclass def run(example_group_instance, reporter) @example_group_instance = example_group_instance + @reporter = reporter hooks.register_global_singleton_context_hooks(self, RSpec.configuration.hooks) RSpec.configuration.configure_example(self) RSpec.current_example = self @@ -196,10 +225,7 @@ def run(example_group_instance, reporter) rescue Exception => e set_exception(e) ensure - ExampleGroup.each_instance_variable_for_example(@example_group_instance) do |ivar| - @example_group_instance.instance_variable_set(ivar, nil) - end - @example_group_instance = nil + @example_group_instance = nil # if you love something... let it go end finish(reporter) @@ -278,29 +304,55 @@ def inspect # @private # - # Used internally to set an exception in an after hook, which - # captures the exception but doesn't raise it. - def set_exception(exception, context=nil) - if pending? && !(Pending::PendingExampleFixedError === exception) - execution_result.pending_exception = exception + # The exception that will be displayed to the user -- either the failure of + # the example or the `pending_exception` if the example is pending. + def display_exception + @exception || execution_result.pending_exception + end + + # @private + # + # Assigns the exception that will be displayed to the user -- either the failure of + # the example or the `pending_exception` if the example is pending. + def display_exception=(ex) + if pending? && !(Pending::PendingExampleFixedError === ex) + @exception = nil + execution_result.pending_fixed = false + execution_result.pending_exception = ex else - if @exception - # An error has already been set; we don't want to override it, - # but we also don't want silence the error, so let's print it. - msg = <<-EOS + @exception = ex + end + end - An error occurred #{context} - #{exception.class}: #{exception.message} - occurred at #{exception.backtrace.first} + # rubocop:disable Style/AccessorMethodName - EOS - RSpec.configuration.reporter.message(msg) - end + # @private + # + # Used internally to set an exception in an after hook, which + # captures the exception but doesn't raise it. + def set_exception(exception) + return self.display_exception = exception unless display_exception - @exception ||= exception + unless RSpec::Core::MultipleExceptionError === display_exception + self.display_exception = RSpec::Core::MultipleExceptionError.new(display_exception) end + + display_exception.add exception + end + + # @private + # + # Used to set the exception when `aggregate_failures` fails. + def set_aggregate_failures_exception(exception) + return set_exception(exception) unless display_exception + + exception = RSpec::Core::MultipleExceptionError::InterfaceTag.for(exception) + exception.add display_exception + self.display_exception = exception end + # rubocop:enable Style/AccessorMethodName + # @private # # Used internally to set an exception and fail without actually executing @@ -321,13 +373,6 @@ def skip_with_exception(reporter, exception) finish(reporter) end - # @private - def instance_exec_with_rescue(context, &block) - @example_group_instance.instance_exec(self, &block) - rescue Exception => e - set_exception(e, context) - end - # @private def instance_exec(*args, &block) @example_group_instance.instance_exec(*args, &block) @@ -342,7 +387,7 @@ def hooks def with_around_example_hooks hooks.run(:around, :example, self) { yield } rescue Exception => e - set_exception(e, "in an `around(:example)` hook") + set_exception(e) end def start(reporter) @@ -398,13 +443,7 @@ def run_after_example def verify_mocks @example_group_instance.verify_mocks_for_rspec if mocks_need_verification? rescue Exception => e - if pending? - execution_result.pending_fixed = false - execution_result.pending_exception = e - @exception = nil - else - set_exception(e) - end + set_exception(e) end def mocks_need_verification? @@ -431,14 +470,6 @@ def location_description "example at #{location}" end - def skip_message - if String === skip - skip - else - Pending::NO_REASON_GIVEN - end - end - # Represents the result of executing an example. # Behaves like a hash for backwards compatibility. class ExecutionResult @@ -529,10 +560,13 @@ def initialize super(AnonymousExampleGroup, "", {}) end + # rubocop:disable Style/AccessorMethodName + # To ensure we don't silence errors. - def set_exception(exception, _context=nil) + def set_exception(exception) raise exception end + # rubocop:enable Style/AccessorMethodName end end end diff --git a/lib/rspec/core/example_group.rb b/lib/rspec/core/example_group.rb index 1738de33a9..97529553ea 100644 --- a/lib/rspec/core/example_group.rb +++ b/lib/rspec/core/example_group.rb @@ -35,7 +35,7 @@ class ExampleGroup # @private def self.idempotently_define_singleton_method(name, &definition) (class << self; self; end).module_exec do - remove_method(name) if method_defined?(name) + remove_method(name) if method_defined?(name) && instance_method(name).owner == self define_method(name, &definition) end end @@ -142,8 +142,9 @@ def self.define_example_method(name, extra_options={}) options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block options.update(extra_options) - examples << RSpec::Core::Example.new(self, desc, options, block) - examples.last + example = RSpec::Core::Example.new(self, desc, options, block) + examples << example + example end end @@ -230,7 +231,7 @@ def self.define_example_method(name, extra_options={}) # @see DSL#describe def self.define_example_group_method(name, metadata={}) idempotently_define_singleton_method(name) do |*args, &example_group_block| - thread_data = RSpec.thread_local_metadata + thread_data = RSpec::Support.thread_local_data top_level = self == ExampleGroup if top_level @@ -359,7 +360,6 @@ def self.find_and_eval_shared(label, name, inclusion_location, *args, &customiza def self.subclass(parent, description, args, &example_group_block) subclass = Class.new(parent) subclass.set_it_up(description, *args, &example_group_block) - ExampleGroups.assign_const(subclass) subclass.module_exec(&example_group_block) if example_group_block # The LetDefinitions module must be included _after_ other modules @@ -372,7 +372,7 @@ def self.subclass(parent, description, args, &example_group_block) end # @private - def self.set_it_up(*args, &example_group_block) + def self.set_it_up(description, *args, &example_group_block) # Ruby 1.9 has a bug that can lead to infinite recursion and a # SystemStackError if you include a module in a superclass after # including it in a subclass: https://gist.github.com/845896 @@ -383,13 +383,14 @@ def self.set_it_up(*args, &example_group_block) # here. ensure_example_groups_are_configured - description = args.shift user_metadata = Metadata.build_hash_from(args) - args.unshift(description) @metadata = Metadata::ExampleGroupHash.create( - superclass_metadata, user_metadata, *args, &example_group_block + superclass_metadata, user_metadata, + superclass.method(:next_runnable_index_for), + description, *args, &example_group_block ) + ExampleGroups.assign_const(self) hooks.register_globals(self, RSpec.configuration.hooks) RSpec.configuration.configure_group(self) @@ -416,6 +417,15 @@ def self.children @children ||= [] end + # @private + def self.next_runnable_index_for(file) + if self == ExampleGroup + RSpec.world.num_example_groups_defined_in(file) + else + children.count + examples.count + end + 1 + end + # @private def self.descendants @_descendants ||= [self] + FlatMap.flat_map(children, &:descendants) @@ -428,7 +438,7 @@ def self.parent_groups # @private def self.top_level? - @top_level ||= superclass == ExampleGroup + superclass == ExampleGroup end # @private @@ -458,7 +468,7 @@ def self.store_before_context_ivars(example_group_instance) def self.run_before_context_hooks(example_group_instance) set_ivars(example_group_instance, superclass_before_context_ivars) - ContextHookMemoizedHash::Before.isolate_for_context_hook(example_group_instance) do + ContextHookMemoized::Before.isolate_for_context_hook(example_group_instance) do hooks.run(:before, :context, example_group_instance) end ensure @@ -471,6 +481,7 @@ def self.superclass_before_context_ivars superclass.before_context_ivars end else # 1.8.7 + # :nocov: # @private def self.superclass_before_context_ivars if superclass.respond_to?(:before_context_ivars) @@ -485,13 +496,14 @@ def self.superclass_before_context_ivars ancestors.find { |a| a.respond_to?(:before_context_ivars) }.before_context_ivars end end + # :nocov: end # @private def self.run_after_context_hooks(example_group_instance) set_ivars(example_group_instance, before_context_ivars) - ContextHookMemoizedHash::After.isolate_for_context_hook(example_group_instance) do + ContextHookMemoized::After.isolate_for_context_hook(example_group_instance) do hooks.run(:after, :context, example_group_instance) end ensure @@ -499,11 +511,8 @@ def self.run_after_context_hooks(example_group_instance) end # Runs all the examples in this group. - def self.run(reporter=RSpec::Core::NullReporter.new) - if RSpec.world.wants_to_quit - RSpec.world.clear_remaining_example_groups if top_level? - return - end + def self.run(reporter=RSpec::Core::NullReporter) + return if RSpec.world.wants_to_quit reporter.example_group_started(self) should_run_context_hooks = descendant_filtered_examples.any? @@ -514,9 +523,11 @@ def self.run(reporter=RSpec::Core::NullReporter.new) result_for_this_group && results_for_descendants rescue Pending::SkipDeclaredInExample => ex for_filtered_examples(reporter) { |example| example.skip_with_exception(reporter, ex) } + true rescue Exception => ex RSpec.world.wants_to_quit = true if fail_fast? for_filtered_examples(reporter) { |example| example.fail_with_exception(reporter, ex) } + false ensure run_after_context_hooks(new('after(:context) hook')) if should_run_context_hooks reporter.example_group_finished(self) @@ -575,6 +586,12 @@ def self.declaration_line_numbers FlatMap.flat_map(children, &:declaration_line_numbers) end + # @return [String] the unique id of this example group. Pass + # this at the command line to re-run this exact example group. + def self.id + Metadata.id_from(metadata) + end + # @private def self.top_level_description parent_groups.last.description @@ -586,8 +603,10 @@ def self.set_ivars(instance, ivars) end if RUBY_VERSION.to_f < 1.9 + # :nocov: # @private INSTANCE_VARIABLE_TO_IGNORE = '@__inspect_output'.freeze + # :nocov: else # @private INSTANCE_VARIABLE_TO_IGNORE = :@__inspect_output @@ -602,6 +621,7 @@ def self.each_instance_variable_for_example(group) def initialize(inspect_output=nil) @__inspect_output = inspect_output || '(no description provided)' + super() # no args get passed end # @private @@ -610,10 +630,12 @@ def inspect end unless method_defined?(:singleton_class) # for 1.8.7 + # :nocov: # @private def singleton_class class << self; self; end end + # :nocov: end # Raised when an RSpec API is called in the wrong scope, such as `before` @@ -688,17 +710,22 @@ def description # @private def self.current_backtrace - RSpec.thread_local_metadata[:shared_example_group_inclusions].reverse + shared_example_group_inclusions.reverse end # @private def self.with_frame(name, location) - current_stack = RSpec.thread_local_metadata[:shared_example_group_inclusions] + current_stack = shared_example_group_inclusions current_stack << new(name, location) yield ensure current_stack.pop end + + # @private + def self.shared_example_group_inclusions + RSpec::Support.thread_local_data[:shared_example_group_inclusions] ||= [] + end end end @@ -745,6 +772,7 @@ def self.base_name_for(group) end if RUBY_VERSION == '1.9.2' + # :nocov: class << self alias _base_name_for base_name_for def base_name_for(group) @@ -752,6 +780,7 @@ def base_name_for(group) end end private_class_method :_base_name_for + # :nocov: end def self.disambiguate(name, const_scope) diff --git a/lib/rspec/core/example_status_persister.rb b/lib/rspec/core/example_status_persister.rb new file mode 100644 index 0000000000..0eb8a0ab52 --- /dev/null +++ b/lib/rspec/core/example_status_persister.rb @@ -0,0 +1,235 @@ +RSpec::Support.require_rspec_support "directory_maker" + +module RSpec + module Core + # Persists example ids and their statuses so that we can filter + # to just the ones that failed the last time they ran. + # @private + class ExampleStatusPersister + def self.load_from(file_name) + return [] unless File.exist?(file_name) + ExampleStatusParser.parse(File.read(file_name)) + end + + def self.persist(examples, file_name) + new(examples, file_name).persist + end + + def initialize(examples, file_name) + @examples = examples + @file_name = file_name + end + + def persist + write dumped_statuses + end + + private + + def write(statuses) + RSpec::Support::DirectoryMaker.mkdir_p(File.dirname(@file_name)) + File.open(@file_name, "w") { |f| f.write(statuses) } + end + + def dumped_statuses + ExampleStatusDumper.dump(merged_statuses) + end + + def merged_statuses + ExampleStatusMerger.merge(statuses_from_this_run, statuses_from_previous_runs) + end + + def statuses_from_this_run + @examples.map do |ex| + result = ex.execution_result + + { + :example_id => ex.id, + :status => result.status ? result.status.to_s : Configuration::UNKNOWN_STATUS, + :run_time => result.run_time ? Formatters::Helpers.format_duration(result.run_time) : "" + } + end + end + + def statuses_from_previous_runs + self.class.load_from(@file_name) + end + end + + # Merges together a list of example statuses from this run + # and a list from previous runs (presumably loaded from disk). + # Each example status object is expected to be a hash with + # at least an `:example_id` and a `:status` key. Examples that + # were loaded but not executed (due to filtering, `--fail-fast` + # or whatever) should have a `:status` of `UNKNOWN_STATUS`. + # + # This willl produce a new list that: + # - Will be missing examples from previous runs that we know for sure + # no longer exist. + # - Will have the latest known status for any examples that either + # definitively do exist or may still exist. + # - Is sorted by file name and example definition order, so that + # the saved file is easily scannable if users want to inspect it. + # @private + class ExampleStatusMerger + def self.merge(this_run, from_previous_runs) + new(this_run, from_previous_runs).merge + end + + def initialize(this_run, from_previous_runs) + @this_run = hash_from(this_run) + @from_previous_runs = hash_from(from_previous_runs) + @file_exists_cache = Hash.new { |hash, file| hash[file] = File.exist?(file) } + end + + def merge + delete_previous_examples_that_no_longer_exist + + @this_run.merge(@from_previous_runs) do |_ex_id, new, old| + new.fetch(:status) == Configuration::UNKNOWN_STATUS ? old : new + end.values.sort_by(&method(:sort_value_from)) + end + + private + + def hash_from(example_list) + example_list.inject({}) do |hash, example| + hash[example.fetch(:example_id)] = example + hash + end + end + + def delete_previous_examples_that_no_longer_exist + @from_previous_runs.delete_if do |ex_id, _| + example_must_no_longer_exist?(ex_id) + end + end + + def example_must_no_longer_exist?(ex_id) + # Obviously, it exists if it was loaded for this spec run... + return false if @this_run.key?(ex_id) + + spec_file = spec_file_from(ex_id) + + # `this_run` includes examples that were loaded but not executed. + # Given that, if the spec file for this example was loaded, + # but the id does not still exist, it's safe to assume that + # the example must no longer exist. + return true if loaded_spec_files.include?(spec_file) + + # The example may still exist as long as the file exists... + !@file_exists_cache[spec_file] + end + + def loaded_spec_files + @loaded_spec_files ||= Set.new(@this_run.keys.map(&method(:spec_file_from))) + end + + def spec_file_from(ex_id) + ex_id.split("[").first + end + + def sort_value_from(example) + file, scoped_id = example.fetch(:example_id).split(Configuration::ON_SQUARE_BRACKETS) + [file, *scoped_id.split(":").map(&method(:Integer))] + end + end + + # Dumps a list of hashes in a pretty, human readable format + # for later parsing. The hashes are expected to have symbol + # keys and string values, and each hash should have the same + # set of keys. + # @private + class ExampleStatusDumper + def self.dump(examples) + new(examples).dump + end + + def initialize(examples) + @examples = examples + end + + def dump + return nil if @examples.empty? + (formatted_header_rows + formatted_value_rows).join("\n") << "\n" + end + + private + + def formatted_header_rows + @formatted_header_rows ||= begin + dividers = column_widths.map { |w| "-" * w } + [formatted_row_from(headers.map(&:to_s)), formatted_row_from(dividers)] + end + end + + def formatted_value_rows + @foramtted_value_rows ||= rows.map do |row| + formatted_row_from(row) + end + end + + def rows + @rows ||= @examples.map { |ex| ex.values_at(*headers) } + end + + def formatted_row_from(row_values) + padded_values = row_values.each_with_index.map do |value, index| + value.ljust(column_widths[index]) + end + + padded_values.join(" | ") << " |" + end + + def headers + @headers ||= @examples.first.keys + end + + def column_widths + @column_widths ||= begin + value_sets = rows.transpose + + headers.each_with_index.map do |header, index| + values = value_sets[index] << header.to_s + values.map(&:length).max + end + end + end + end + + # Parses a string that has been previously dumped by ExampleStatusDumper. + # Note that this parser is a bit naive in that it does a simple split on + # "\n" and " | ", with no concern for handling escaping. For now, that's + # OK because the values we plan to persist (example id, status, and perhaps + # example duration) are highly unlikely to contain "\n" or " | " -- after + # all, who puts those in file names? + # @private + class ExampleStatusParser + def self.parse(string) + new(string).parse + end + + def initialize(string) + @header_line, _, *@row_lines = string.lines.to_a + end + + def parse + @row_lines.map { |line| parse_row(line) } + end + + private + + def parse_row(line) + Hash[headers.zip(split_line(line))] + end + + def headers + @headers ||= split_line(@header_line).grep(/\S/).map(&:to_sym) + end + + def split_line(line) + line.split(/\s+\|\s+?/, -1) + end + end + end +end diff --git a/lib/rspec/core/filter_manager.rb b/lib/rspec/core/filter_manager.rb index b3e3217e20..e5e062a7ed 100644 --- a/lib/rspec/core/filter_manager.rb +++ b/lib/rspec/core/filter_manager.rb @@ -16,9 +16,15 @@ def add_location(file_path, line_numbers) # locations is a hash of expanded paths to arrays of line # numbers to match against. e.g. # { "path/to/file.rb" => [37, 42] } - locations = inclusions.delete(:locations) || Hash.new { |h, k| h[k] = [] } - locations[File.expand_path(file_path)].push(*line_numbers) - inclusions.add(:locations => locations) + add_path_to_arrays_filter(:locations, File.expand_path(file_path), line_numbers) + end + + def add_ids(rerun_path, scoped_ids) + # ids is a hash of relative paths to arrays of ids + # to match against. e.g. + # { "./path/to/file.rb" => ["1:1", "2:4"] } + rerun_path = Metadata.relative_path(File.expand_path rerun_path) + add_path_to_arrays_filter(:ids, rerun_path, scoped_ids) end def empty? @@ -26,13 +32,25 @@ def empty? end def prune(examples) + # Semantically, this is unnecessary (the filtering below will return the empty + # array unmodified), but for perf reasons it's worth exiting early here. Users + # commonly have top-level examples groups that do not have any direct examples + # and instead have nested groups with examples. In that kind of situation, + # `examples` will be empty. + return examples if examples.empty? + examples = prune_conditionally_filtered_examples(examples) if inclusions.standalone? - examples.select { |e| include?(e) } + examples.select { |e| inclusions.include_example?(e) } else - locations = inclusions.fetch(:locations) { Hash.new([]) } - examples.select { |e| priority_include?(e, locations) || (!exclude?(e) && include?(e)) } + locations, ids, non_scoped_inclusions = inclusions.split_file_scoped_rules + + examples.select do |ex| + file_scoped_include?(ex.metadata, ids, locations) do + !exclusions.include_example?(ex) && non_scoped_inclusions.include_example?(ex) + end + end end end @@ -62,12 +80,10 @@ def include_with_low_priority(*args) private - def exclude?(example) - exclusions.include_example?(example) - end - - def include?(example) - inclusions.include_example?(example) + def add_path_to_arrays_filter(filter_key, path, values) + filter = inclusions.delete(filter_key) || Hash.new { |h, k| h[k] = [] } + filter[path].concat(values) + inclusions.add(filter_key => filter) end def prune_conditionally_filtered_examples(examples) @@ -82,9 +98,16 @@ def prune_conditionally_filtered_examples(examples) # and there is a `:slow => true` exclusion filter), but only for specs # defined in the same file as the location filters. Excluded specs in # other files should still be excluded. - def priority_include?(example, locations) - return false if locations[example.metadata[:absolute_file_path]].empty? - MetadataFilter.filter_applies?(:locations, locations, example.metadata) + def file_scoped_include?(ex_metadata, ids, locations) + no_id_filters = ids[ex_metadata[:rerun_file_path]].empty? + no_location_filters = locations[ + File.expand_path(ex_metadata[:rerun_file_path]) + ].empty? + + return yield if no_location_filters && no_id_filters + + MetadataFilter.filter_applies?(:ids, ids, ex_metadata) || + MetadataFilter.filter_applies?(:locations, locations, ex_metadata) end end @@ -104,8 +127,8 @@ def self.build [exclusions, inclusions] end - def initialize(*args, &block) - @rules = Hash.new(*args, &block) + def initialize(rules={}) + @rules = rules end def add(updated) @@ -169,10 +192,6 @@ def add_with_low_priority(*args) apply_standalone_filter(*args) || super end - def use(*args) - apply_standalone_filter(*args) || super - end - def include_example?(example) @rules.empty? || super end @@ -181,6 +200,14 @@ def standalone? is_standalone_filter?(@rules) end + def split_file_scoped_rules + rules_dup = @rules.dup + locations = rules_dup.delete(:locations) { Hash.new([]) } + ids = rules_dup.delete(:ids) { Hash.new([]) } + + return locations, ids, self.class.new(rules_dup) + end + private def apply_standalone_filter(updated) diff --git a/lib/rspec/core/flat_map.rb b/lib/rspec/core/flat_map.rb index 71093ac832..0e30cceb51 100644 --- a/lib/rspec/core/flat_map.rb +++ b/lib/rspec/core/flat_map.rb @@ -7,9 +7,11 @@ def flat_map(array, &block) array.flat_map(&block) end else # for 1.8.7 + # :nocov: def flat_map(array, &block) array.map(&block).flatten(1) end + # :nocov: end module_function :flat_map diff --git a/lib/rspec/core/formatters.rb b/lib/rspec/core/formatters.rb index 765c2e1f45..368825b39a 100644 --- a/lib/rspec/core/formatters.rb +++ b/lib/rspec/core/formatters.rb @@ -66,11 +66,13 @@ # @see RSpec::Core::Formatters::BaseTextFormatter # @see RSpec::Core::Reporter module RSpec::Core::Formatters - autoload :DocumentationFormatter, 'rspec/core/formatters/documentation_formatter' - autoload :HtmlFormatter, 'rspec/core/formatters/html_formatter' - autoload :ProgressFormatter, 'rspec/core/formatters/progress_formatter' - autoload :ProfileFormatter, 'rspec/core/formatters/profile_formatter' - autoload :JsonFormatter, 'rspec/core/formatters/json_formatter' + autoload :DocumentationFormatter, 'rspec/core/formatters/documentation_formatter' + autoload :HtmlFormatter, 'rspec/core/formatters/html_formatter' + autoload :FallbackMessageFormatter, 'rspec/core/formatters/fallback_message_formatter' + autoload :ProgressFormatter, 'rspec/core/formatters/progress_formatter' + autoload :ProfileFormatter, 'rspec/core/formatters/profile_formatter' + autoload :JsonFormatter, 'rspec/core/formatters/json_formatter' + autoload :BisectFormatter, 'rspec/core/formatters/bisect_formatter' # Register the formatter class # @param formatter_class [Class] formatter class to register @@ -120,7 +122,15 @@ def setup_default(output_stream, deprecation_stream) add DeprecationFormatter, deprecation_stream, output_stream end - return unless RSpec.configuration.profile_examples? && !existing_formatter_implements?(:dump_profile) + unless existing_formatter_implements?(:message) + add FallbackMessageFormatter, output_stream + end + + return unless RSpec.configuration.profile_examples? + + @reporter.setup_profiler + + return if existing_formatter_implements?(:dump_profile) add RSpec::Core::Formatters::ProfileFormatter, output_stream end @@ -133,18 +143,12 @@ def add(formatter_to_use, *paths) if !Loader.formatters[formatter_class].nil? formatter = formatter_class.new(*args) - @reporter.register_listener formatter, *notifications_for(formatter_class) + register formatter, notifications_for(formatter_class) elsif defined?(RSpec::LegacyFormatters) formatter = RSpec::LegacyFormatters.load_formatter formatter_class, *args - @reporter.register_listener formatter, *formatter.notifications + register formatter, formatter.notifications else - line = ::RSpec::CallerFilter.first_non_rspec_line - if line - call_site = "Formatter added at: #{line}" - else - call_site = "The formatter was added via command line flag or your "\ - "`.rspec` file." - end + call_site = "Formatter added at: #{::RSpec::CallerFilter.first_non_rspec_line}" RSpec.warn_deprecation <<-WARNING.gsub(/\s*\|/, ' ') |The #{formatter_class} formatter uses the deprecated formatter @@ -157,10 +161,7 @@ def add(formatter_to_use, *paths) | |#{call_site} WARNING - return end - @formatters << formatter unless duplicate_formatter_exists?(formatter) - formatter end private @@ -172,6 +173,13 @@ def find_formatter(formatter_to_use) "maybe you meant 'documentation' or 'progress'?.") end + def register(formatter, notifications) + return if duplicate_formatter_exists?(formatter) + @reporter.register_listener formatter, *notifications + @formatters << formatter + formatter + end + def duplicate_formatter_exists?(new_formatter) @formatters.any? do |formatter| formatter.class === new_formatter && formatter.output == new_formatter.output @@ -192,12 +200,14 @@ def built_in_formatter(key) ProgressFormatter when 'j', 'json' JsonFormatter + when 'bisect' + BisectFormatter end end def notifications_for(formatter_class) - formatter_class.ancestors.inject(Set.new) do |notifications, klass| - notifications + Loader.formatters.fetch(klass) { Set.new } + formatter_class.ancestors.inject(::RSpec::Core::Set.new) do |notifications, klass| + notifications.merge Loader.formatters.fetch(klass) { ::RSpec::Core::Set.new } end end diff --git a/lib/rspec/core/formatters/base_text_formatter.rb b/lib/rspec/core/formatters/base_text_formatter.rb index 03b79fb18a..262a8d3dda 100644 --- a/lib/rspec/core/formatters/base_text_formatter.rb +++ b/lib/rspec/core/formatters/base_text_formatter.rb @@ -68,6 +68,7 @@ def close(_notification) output.puts + output.flush output.close unless output == $stdout end end diff --git a/lib/rspec/core/formatters/bisect_formatter.rb b/lib/rspec/core/formatters/bisect_formatter.rb new file mode 100644 index 0000000000..60d86789a6 --- /dev/null +++ b/lib/rspec/core/formatters/bisect_formatter.rb @@ -0,0 +1,68 @@ +require 'drb/drb' + +module RSpec + module Core + module Formatters + # Used by `--bisect`. When it shells out and runs a portion of the suite, it uses + # this formatter as a means to have the status reported back to it, via DRb. + # + # Note that since DRb calls carry considerable overhead compared to normal + # method calls, we try to minimize the number of DRb calls for perf reasons, + # opting to communicate only at the start and the end of the run, rather than + # after each example. + # @private + class BisectFormatter + Formatters.register self, :start, :start_dump, :example_started, + :example_failed, :example_passed, :example_pending + + def initialize(_output) + port = RSpec.configuration.drb_port + drb_uri = "druby://localhost:#{port}" + @all_example_ids = [] + @failed_example_ids = [] + @bisect_server = DRbObject.new_with_uri(drb_uri) + @remaining_failures = [] + end + + def start(_notification) + @remaining_failures = Set.new(@bisect_server.expected_failures) + end + + def example_started(notification) + @all_example_ids << notification.example.id + end + + def example_failed(notification) + @failed_example_ids << notification.example.id + example_finished(notification, :failed) + end + + def example_passed(notification) + example_finished(notification, :passed) + end + + def example_pending(notification) + example_finished(notification, :pending) + end + + def start_dump(_notification) + @bisect_server.latest_run_results = RunResults.new( + @all_example_ids, @failed_example_ids + ) + end + + RunResults = Struct.new(:all_example_ids, :failed_example_ids) + + private + + def example_finished(notification, status) + return unless @remaining_failures.include?(notification.example.id) + @remaining_failures.delete(notification.example.id) + + return if status == :failed && !@remaining_failures.empty? + RSpec.world.wants_to_quit = true + end + end + end + end +end diff --git a/lib/rspec/core/formatters/bisect_progress_formatter.rb b/lib/rspec/core/formatters/bisect_progress_formatter.rb new file mode 100644 index 0000000000..3b12c219ef --- /dev/null +++ b/lib/rspec/core/formatters/bisect_progress_formatter.rb @@ -0,0 +1,115 @@ +RSpec::Support.require_rspec_core "formatters/base_text_formatter" + +module RSpec + module Core + module Formatters + # @private + # Produces progress output while bisecting. + class BisectProgressFormatter < BaseTextFormatter + # We've named all events with a `bisect_` prefix to prevent naming collisions. + Formatters.register self, :bisect_starting, :bisect_original_run_complete, + :bisect_round_started, :bisect_individual_run_complete, + :bisect_round_finished, :bisect_complete, :bisect_repro_command, + :bisect_failed, :bisect_aborted + + def bisect_starting(notification) + options = notification.original_cli_args.join(' ') + output.puts "Bisect started using options: #{options.inspect}" + output.print "Running suite to find failures..." + end + + def bisect_original_run_complete(notification) + failures = Helpers.pluralize(notification.failed_example_ids.size, "failing example") + non_failures = Helpers.pluralize(notification.non_failing_example_ids.size, "non-failing example") + + output.puts " (#{Helpers.format_duration(notification.duration)})" + output.puts "Starting bisect with #{failures} and #{non_failures}." + end + + def bisect_round_started(notification, include_trailing_space=true) + search_desc = Helpers.pluralize( + notification.subset_size, "non-failing example" + ) + + output.print "\nRound #{notification.round}: searching for #{search_desc}" \ + " (of #{notification.remaining_count}) to ignore:" + output.print " " if include_trailing_space + end + + def bisect_round_finished(notification) + output.print " (#{Helpers.format_duration(notification.duration)})" + end + + def bisect_individual_run_complete(_) + output.print '.' + end + + def bisect_complete(notification) + output.puts "\nBisect complete! Reduced necessary non-failing examples " \ + "from #{notification.original_non_failing_count} to " \ + "#{notification.remaining_count} in " \ + "#{Helpers.format_duration(notification.duration)}." + end + + def bisect_repro_command(notification) + output.puts "\nThe minimal reproduction command is:\n #{notification.repro}" + end + + def bisect_failed(notification) + output.puts "\nBisect failed! #{notification.failure_explanation}" + end + + def bisect_aborted(notification) + output.puts "\n\nBisect aborted!" + output.puts "\nThe most minimal reproduction command discovered so far is:\n #{notification.repro}" + end + end + + # @private + # Produces detailed debug output while bisecting. Used when + # bisect is performed while the `DEBUG_RSPEC_BISECT` ENV var is used. + # Designed to provide details for us when we need to troubleshoot bisect bugs. + class BisectDebugFormatter < BisectProgressFormatter + Formatters.register self, :bisect_original_run_complete, :bisect_individual_run_start, + :bisect_individual_run_complete, :bisect_round_finished, :bisect_ignoring_ids + + def bisect_original_run_complete(notification) + output.puts " (#{Helpers.format_duration(notification.duration)})" + + output.puts " - #{describe_ids 'Failing examples', notification.failed_example_ids}" + output.puts " - #{describe_ids 'Non-failing examples', notification.non_failing_example_ids}" + end + + def bisect_individual_run_start(notification) + output.print "\n - Running: #{notification.command}" + end + + def bisect_individual_run_complete(notification) + output.print " (#{Helpers.format_duration(notification.duration)})" + end + + def bisect_round_started(notification) + super(notification, false) + end + + def bisect_round_finished(notification) + output.print "\n - Round finished" + super + end + + def bisect_ignoring_ids(notification) + output.print "\n - #{describe_ids 'Examples we can safely ignore', notification.ids_to_ignore}" + output.print "\n - #{describe_ids 'Remaining non-failing examples', notification.remaining_ids}" + end + + private + + def describe_ids(description, ids) + organized_ids = Formatters::Helpers.organize_ids(ids) + formatted_ids = organized_ids.map { |id| " - #{id}" }.join("\n") + "#{description} (#{ids.size}):\n#{formatted_ids}" + end + end + end + end +end diff --git a/lib/rspec/core/formatters/deprecation_formatter.rb b/lib/rspec/core/formatters/deprecation_formatter.rb index 118fe88761..ab9096260e 100644 --- a/lib/rspec/core/formatters/deprecation_formatter.rb +++ b/lib/rspec/core/formatters/deprecation_formatter.rb @@ -1,5 +1,4 @@ RSpec::Support.require_rspec_core "formatters/helpers" -require 'set' module RSpec module Core diff --git a/lib/rspec/core/formatters/documentation_formatter.rb b/lib/rspec/core/formatters/documentation_formatter.rb index 5deb4a754f..fd50c8758b 100644 --- a/lib/rspec/core/formatters/documentation_formatter.rb +++ b/lib/rspec/core/formatters/documentation_formatter.rb @@ -64,10 +64,6 @@ def next_failure_index def current_indentation ' ' * @group_level end - - def example_group_chain - example_group.parent_groups.reverse - end end end end diff --git a/lib/rspec/core/formatters/exception_presenter.rb b/lib/rspec/core/formatters/exception_presenter.rb new file mode 100644 index 0000000000..c97fece5e0 --- /dev/null +++ b/lib/rspec/core/formatters/exception_presenter.rb @@ -0,0 +1,389 @@ +module RSpec + module Core + module Formatters + # @private + class ExceptionPresenter + attr_reader :exception, :example, :description, :message_color, + :detail_formatter, :extra_detail_formatter, :backtrace_formatter + private :message_color, :detail_formatter, :extra_detail_formatter, :backtrace_formatter + + def initialize(exception, example, options={}) + @exception = exception + @example = example + @message_color = options.fetch(:message_color) { RSpec.configuration.failure_color } + @description = options.fetch(:description_formatter) { Proc.new { example.full_description } }.call(self) + @detail_formatter = options.fetch(:detail_formatter) { Proc.new {} } + @extra_detail_formatter = options.fetch(:extra_detail_formatter) { Proc.new {} } + @backtrace_formatter = options.fetch(:backtrace_formatter) { RSpec.configuration.backtrace_formatter } + @indentation = options.fetch(:indentation, 2) + @skip_shared_group_trace = options.fetch(:skip_shared_group_trace, false) + @failure_lines = options[:failure_lines] + end + + def message_lines + add_shared_group_lines(failure_lines, Notifications::NullColorizer) + end + + def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes) + add_shared_group_lines(failure_lines, colorizer).map do |line| + colorizer.wrap line, message_color + end + end + + def formatted_backtrace + backtrace_formatter.format_backtrace(exception.backtrace, example.metadata) + end + + def colorized_formatted_backtrace(colorizer=::RSpec::Core::Formatters::ConsoleCodes) + formatted_backtrace.map do |backtrace_info| + colorizer.wrap "# #{backtrace_info}", RSpec.configuration.detail_color + end + end + + def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) + alignment_basis = "#{' ' * @indentation}#{failure_number}) " + indentation = ' ' * alignment_basis.length + + "\n#{alignment_basis}#{description_and_detail(colorizer, indentation)}" \ + "\n#{formatted_message_and_backtrace(colorizer, indentation)}" \ + "#{extra_detail_formatter.call(failure_number, colorizer, indentation)}" + end + + def failure_slash_error_line + @failure_slash_error_line ||= "Failure/Error: #{read_failed_line.strip}" + end + + private + + def description_and_detail(colorizer, indentation) + detail = detail_formatter.call(example, colorizer, indentation) + return (description || detail) unless description && detail + "#{description}\n#{indentation}#{detail}" + end + + if String.method_defined?(:encoding) + def encoding_of(string) + string.encoding + end + + def encoded_string(string) + RSpec::Support::EncodedString.new(string, Encoding.default_external) + end + else # for 1.8.7 + # :nocov: + def encoding_of(_string) + end + + def encoded_string(string) + RSpec::Support::EncodedString.new(string) + end + # :nocov: + end + + def exception_class_name + name = exception.class.name.to_s + name = "(anonymous error class)" if name == '' + name + end + + def failure_lines + @failure_lines ||= + begin + lines = [] + lines << failure_slash_error_line unless (description == failure_slash_error_line) + lines << "#{exception_class_name}:" unless exception_class_name =~ /RSpec/ + encoded_string(exception.message.to_s).split("\n").each do |line| + lines << " #{line}" + end + lines + end + end + + def add_shared_group_lines(lines, colorizer) + return lines if @skip_shared_group_trace + + example.metadata[:shared_group_inclusion_backtrace].each do |frame| + lines << colorizer.wrap(frame.description, RSpec.configuration.default_color) + end + + lines + end + + def read_failed_line + matching_line = find_failed_line + unless matching_line + return "Unable to find matching line from backtrace" + end + + file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2] + + if File.exist?(file_path) + File.readlines(file_path)[line_number.to_i - 1] || + "Unable to find matching line in #{file_path}" + else + "Unable to find #{file_path} to read failed line" + end + rescue SecurityError + "Unable to read failed line" + end + + def find_failed_line + example_path = example.metadata[:absolute_file_path].downcase + exception.backtrace.find do |line| + next unless (line_path = line[/(.+?):(\d+)(|:\d+)/, 1]) + File.expand_path(line_path).downcase == example_path + end + end + + def formatted_message_and_backtrace(colorizer, indentation) + lines = colorized_message_lines(colorizer) + colorized_formatted_backtrace(colorizer) + + formatted = "" + + lines.each do |line| + formatted << RSpec::Support::EncodedString.new("#{indentation}#{line}\n", encoding_of(formatted)) + end + + formatted + end + + # @private + # Configuring the `ExceptionPresenter` with the right set of options to handle + # pending vs failed vs skipped and aggregated (or not) failures is not simple. + # This class takes care of building an appropriate `ExceptionPresenter` for the + # provided example. + class Factory + def build + ExceptionPresenter.new(@exception, @example, options) + end + + private + + def initialize(example) + @example = example + @execution_result = example.execution_result + @exception = if @execution_result.status == :pending + @execution_result.pending_exception + else + @execution_result.exception + end + end + + def options + with_multiple_error_options_as_needed(@exception, pending_options || {}) + end + + def pending_options + if @execution_result.pending_fixed? + { + :description_formatter => Proc.new { "#{@example.full_description} FIXED" }, + :message_color => RSpec.configuration.fixed_color, + :failure_lines => [ + "Expected pending '#{@execution_result.pending_message}' to fail. No Error was raised." + ] + } + elsif @execution_result.status == :pending + { + :message_color => RSpec.configuration.pending_color, + :detail_formatter => PENDING_DETAIL_FORMATTER + } + end + end + + def with_multiple_error_options_as_needed(exception, options) + return options unless multiple_exceptions_error?(exception) + + options = options.merge( + :failure_lines => [], + :extra_detail_formatter => sub_failure_list_formatter(exception, options[:message_color]), + :detail_formatter => multiple_exception_summarizer(exception, + options[:detail_formatter], + options[:message_color]) + ) + + options[:description_formatter] &&= Proc.new {} + + return options unless exception.aggregation_metadata[:hide_backtrace] + options[:backtrace_formatter] = EmptyBacktraceFormatter + options + end + + def multiple_exceptions_error?(exception) + MultipleExceptionError::InterfaceTag === exception + end + + def multiple_exception_summarizer(exception, prior_detail_formatter, color) + lambda do |example, colorizer, indentation| + summary = if exception.aggregation_metadata[:hide_backtrace] + # Since the backtrace is hidden, the subfailures will come + # immediately after this, and using `:` will read well. + "Got #{exception.exception_count_description}:" + else + # The backtrace comes after this, so using a `:` doesn't make sense + # since the failures may be many lines below. + "#{exception.summary}." + end + + summary = colorizer.wrap(summary, color || RSpec.configuration.failure_color) + return summary unless prior_detail_formatter + "#{prior_detail_formatter.call(example, colorizer, indentation)}\n#{indentation}#{summary}" + end + end + + def sub_failure_list_formatter(exception, message_color) + common_backtrace_truncater = CommonBacktraceTruncater.new(exception) + + lambda do |failure_number, colorizer, indentation| + exception.all_exceptions.each_with_index.map do |failure, index| + options = with_multiple_error_options_as_needed( + failure, + :description_formatter => :failure_slash_error_line.to_proc, + :indentation => indentation.length, + :message_color => message_color || RSpec.configuration.failure_color, + :skip_shared_group_trace => true + ) + + failure = common_backtrace_truncater.with_truncated_backtrace(failure) + presenter = ExceptionPresenter.new(failure, @example, options) + presenter.fully_formatted("#{failure_number}.#{index + 1}", colorizer) + end.join + end + end + + # @private + # Used to prevent a confusing backtrace from showing up from the `aggregate_failures` + # block declared for `:aggregate_failures` metadata. + module EmptyBacktraceFormatter + def self.format_backtrace(*) + [] + end + end + + # @private + class CommonBacktraceTruncater + def initialize(parent) + @parent = parent + end + + def with_truncated_backtrace(child) + child_bt = child.backtrace + parent_bt = @parent.backtrace + return child if child_bt.nil? || child_bt.empty? || parent_bt.nil? + + index_before_first_common_frame = -1.downto(-child_bt.size).find do |index| + parent_bt[index] != child_bt[index] + end + + return child if index_before_first_common_frame == -1 + + child = child.dup + child.set_backtrace(child_bt[0..index_before_first_common_frame]) + child + end + end + end + + # @private + PENDING_DETAIL_FORMATTER = Proc.new do |example, colorizer| + colorizer.wrap("# #{example.execution_result.pending_message}", :detail) + end + end + end + + # Provides a single exception instance that provides access to + # multiple sub-exceptions. This is used in situations where a single + # individual spec has multiple exceptions, such as one in the `it` block + # and one in an `after` block. + class MultipleExceptionError < StandardError + # @private + # Used so there is a common module in the ancestor chain of this class + # and `RSpec::Expectations::MultipleExpectationsNotMetError`, which allows + # code to detect exceptions that are instances of either, without first + # checking to see if rspec-expectations is loaded. + module InterfaceTag + # Appends the provided exception to the list. + # @param exception [Exception] Exception to append to the list. + # @private + def add(exception) + # `PendingExampleFixedError` can be assigned to an example that initially has no + # failures, but when the `aggregate_failures` around hook completes, it notifies of + # a failure. If we do not ignore `PendingExampleFixedError` it would be surfaced to + # the user as part of a multiple exception error, which is undesirable. While it's + # pretty weird we handle this here, it's the best solution I've been able to come + # up with, and `PendingExampleFixedError` always represents the _lack_ of any exception + # so clearly when we are transitioning to a `MultipleExceptionError`, it makes sense to + # ignore it. + return if Pending::PendingExampleFixedError === exception + + all_exceptions << exception + + if exception.class.name =~ /RSpec/ + failures << exception + else + other_errors << exception + end + end + + # Provides a way to force `ex` to be something that satisfies the multiple + # exception error interface. If it already satisfies it, it will be returned; + # otherwise it will wrap it in a `MultipleExceptionError`. + # @private + def self.for(ex) + return ex if self === ex + MultipleExceptionError.new(ex) + end + end + + include InterfaceTag + + # @return [Array] The list of failures. + attr_reader :failures + + # @return [Array] The list of other errors. + attr_reader :other_errors + + # @return [Array] The list of failures and other exceptions, combined. + attr_reader :all_exceptions + + # @return [Hash] Metadata used by RSpec for formatting purposes. + attr_reader :aggregation_metadata + + # @return [nil] Provided only for interface compatibility with + # `RSpec::Expectations::MultipleExpectationsNotMetError`. + attr_reader :aggregation_block_label + + # @param exceptions [Array] The initial list of exceptions. + def initialize(*exceptions) + super() + + @failures = [] + @other_errors = [] + @all_exceptions = [] + @aggregation_metadata = { :hide_backtrace => true } + @aggregation_block_label = nil + + exceptions.each { |e| add e } + end + + # @return [String] Combines all the exception messages into a single string. + # @note RSpec does not actually use this -- instead it formats each exception + # individually. + def message + all_exceptions.map(&:message).join("\n\n") + end + + # @return [String] A summary of the failure, including the block label and a count of failures. + def summary + "Got #{exception_count_description}" + end + + # return [String] A description of the failure/error counts. + def exception_count_description + failure_count = Formatters::Helpers.pluralize(failures.size, "failure") + return failure_count if other_errors.empty? + error_count = Formatters::Helpers.pluralize(other_errors.size, "other error") + "#{failure_count} and #{error_count}" + end + end + end +end diff --git a/lib/rspec/core/formatters/fallback_message_formatter.rb b/lib/rspec/core/formatters/fallback_message_formatter.rb new file mode 100644 index 0000000000..db4423ff18 --- /dev/null +++ b/lib/rspec/core/formatters/fallback_message_formatter.rb @@ -0,0 +1,28 @@ +module RSpec + module Core + module Formatters + # @api private + # Formatter for providing message output as a fallback when no other + # profiler implements #message + class FallbackMessageFormatter + Formatters.register self, :message + + def initialize(output) + @output = output + end + + # @private + attr_reader :output + + # @api public + # + # Used by the reporter to send messages to the output stream. + # + # @param notification [MessageNotification] containing message + def message(notification) + output.puts notification.message + end + end + end + end +end diff --git a/lib/rspec/core/formatters/helpers.rb b/lib/rspec/core/formatters/helpers.rb index f6acca2a1c..3d3b0f58d1 100644 --- a/lib/rspec/core/formatters/helpers.rb +++ b/lib/rspec/core/formatters/helpers.rb @@ -1,3 +1,5 @@ +RSpec::Support.require_rspec_core "shell_escape" + module RSpec module Core module Formatters @@ -65,11 +67,13 @@ def self.format_seconds(float, precision=nil) # # Remove trailing zeros from a string. # + # Only remove trailing zeros after a decimal place. + # see: http://rubular.com/r/ojtTydOgpn + # # @param string [String] string with trailing zeros # @return [String] string with trailing zeros removed def self.strip_trailing_zeroes(string) - stripped = string.sub(/[^1-9]+$/, '') - stripped.empty? ? "0" : stripped + string.sub(/(?:(\..*[^0])0+|\.0+)$/, '\1') end private_class_method :strip_trailing_zeroes @@ -83,6 +87,22 @@ def self.strip_trailing_zeroes(string) def self.pluralize(count, string) "#{count} #{string}#{'s' unless count.to_f == 1}" end + + # @api private + # Given a list of example ids, organizes them into a compact, ordered list. + def self.organize_ids(ids) + grouped = ids.inject(Hash.new { |h, k| h[k] = [] }) do |hash, id| + file, id = id.split(Configuration::ON_SQUARE_BRACKETS) + hash[file] << id + hash + end + + grouped.sort_by(&:first).map do |file, grouped_ids| + grouped_ids = grouped_ids.sort_by { |id| id.split(':').map(&:to_i) } + id = Metadata.id_from(:rerun_file_path => file, :scoped_id => grouped_ids.join(',')) + ShellEscape.conditionally_quote(id) + end + end end end end diff --git a/lib/rspec/core/formatters/html_formatter.rb b/lib/rspec/core/formatters/html_formatter.rb index 30868b45ba..bc348fa7a1 100644 --- a/lib/rspec/core/formatters/html_formatter.rb +++ b/lib/rspec/core/formatters/html_formatter.rb @@ -74,8 +74,6 @@ def example_failed(failure) :message => exception.message, :backtrace => failure.formatted_backtrace.join("\n") } - else - false end extra = extra_failure_content(failure) @@ -85,8 +83,7 @@ def example_failed(failure) example.execution_result.run_time, @failed_examples.size, exception_details, - (extra == "") ? false : extra, - true + (extra == "") ? false : extra ) @printer.flush end diff --git a/lib/rspec/core/formatters/html_printer.rb b/lib/rspec/core/formatters/html_printer.rb index 32a9f97d39..dfb68a94ae 100644 --- a/lib/rspec/core/formatters/html_printer.rb +++ b/lib/rspec/core/formatters/html_printer.rb @@ -35,7 +35,7 @@ def print_example_passed(description, run_time) # rubocop:disable Style/ParameterLists def print_example_failed(pending_fixed, description, run_time, failure_id, - exception, extra_content, escape_backtrace=false) + exception, extra_content) # rubocop:enable Style/ParameterLists formatted_run_time = "%.5f" % run_time @@ -45,11 +45,7 @@ def print_example_failed(pending_fixed, description, run_time, failure_id, @output.puts "
" if exception @output.puts "
#{h(exception[:message])}
" - if escape_backtrace - @output.puts "
#{h exception[:backtrace]}
" - else - @output.puts "
#{exception[:backtrace]}
" - end + @output.puts "
#{h exception[:backtrace]}
" end @output.puts extra_content if extra_content @output.puts "
" diff --git a/lib/rspec/core/formatters/json_formatter.rb b/lib/rspec/core/formatters/json_formatter.rb index 080c10568d..510a80c648 100644 --- a/lib/rspec/core/formatters/json_formatter.rb +++ b/lib/rspec/core/formatters/json_formatter.rb @@ -12,7 +12,9 @@ class JsonFormatter < BaseFormatter def initialize(output) super - @output_hash = {} + @output_hash = { + :version => RSpec::Core::Version::STRING + } end def message(notification) @@ -58,8 +60,7 @@ def dump_profile(profile) # @api private def dump_profile_slowest_examples(profile) @output_hash[:profile] = {} - sorted_examples = profile.slowest_examples - @output_hash[:profile][:examples] = sorted_examples.map do |example| + @output_hash[:profile][:examples] = profile.slowest_examples.map do |example| format_example(example).tap do |hash| hash[:run_time] = example.execution_result.run_time end @@ -85,7 +86,8 @@ def format_example(example) :status => example.execution_result.status.to_s, :file_path => example.metadata[:file_path], :line_number => example.metadata[:line_number], - :run_time => example.execution_result.run_time + :run_time => example.execution_result.run_time, + :pending_message => example.execution_result.pending_message, } end end diff --git a/lib/rspec/core/formatters/snippet_extractor.rb b/lib/rspec/core/formatters/snippet_extractor.rb index 2546acae69..bae3132d40 100644 --- a/lib/rspec/core/formatters/snippet_extractor.rb +++ b/lib/rspec/core/formatters/snippet_extractor.rb @@ -7,26 +7,30 @@ module Formatters # and applies synax highlighting and line numbers using html. class SnippetExtractor # @private - class NullConverter - def convert(code) + module NullConverter + def self.convert(code) %Q(#{code}\n# Install the coderay gem to get syntax highlighting) end end # @private - class CoderayConverter - def convert(code) + module CoderayConverter + def self.convert(code) CodeRay.scan(code, :ruby).html(:line_numbers => false) end end + # rubocop:disable Style/ClassVars + @@converter = NullConverter begin require 'coderay' - # rubocop:disable Style/ClassVars - @@converter = CoderayConverter.new + @@converter = CoderayConverter + # rubocop:disable Lint/HandleExceptions rescue LoadError - @@converter = NullConverter.new + # it'll fall back to the NullConverter assigned above + # rubocop:enable Lint/HandleExceptions end + # rubocop:enable Style/ClassVars # @api private @@ -43,6 +47,7 @@ def snippet(backtrace) highlighted = @@converter.convert(raw_code) post_process(highlighted, line) end + # rubocop:enable Style/ClassVars # @api private # diff --git a/lib/rspec/core/hooks.rb b/lib/rspec/core/hooks.rb index 9c5601777c..3d020b0d56 100644 --- a/lib/rspec/core/hooks.rb +++ b/lib/rspec/core/hooks.rb @@ -361,7 +361,9 @@ def run(example) # @private class AfterHook < Hook def run(example) - example.instance_exec_with_rescue("in an after hook", &block) + example.instance_exec(example, &block) + rescue Exception => ex + example.set_exception(ex) end end @@ -394,10 +396,12 @@ def execute_with(example, procsy) def hook_description "around hook at #{Metadata.relative_path(block.source_location.join(':'))}" end - else + else # for 1.8.7 + # :nocov: def hook_description "around hook" end + # :nocov: end end @@ -622,9 +626,11 @@ def owner_parent_groups @owner.parent_groups end else # Ruby < 2.1 (see https://bugs.ruby-lang.org/issues/8035) + # :nocov: def owner_parent_groups @owner_parent_groups ||= [@owner] + @owner.parent_groups end + # :nocov: end end end diff --git a/lib/rspec/core/memoized_helpers.rb b/lib/rspec/core/memoized_helpers.rb index bc95a07ded..f7d904f121 100644 --- a/lib/rspec/core/memoized_helpers.rb +++ b/lib/rspec/core/memoized_helpers.rb @@ -1,3 +1,5 @@ +require 'rspec/core/reentrant_mutex' + module RSpec module Core # This module is included in {ExampleGroup}, making the methods @@ -53,11 +55,9 @@ module MemoizedHelpers # @see #should_not # @see #is_expected def subject - __memoized.fetch(:subject) do - __memoized[:subject] = begin - described = described_class || self.class.metadata.fetch(:description_args).first - Class === described ? described.new : described - end + __memoized.fetch_or_store(:subject) do + described = described_class || self.class.metadata.fetch(:description_args).first + Class === described ? described.new : described end end @@ -119,33 +119,85 @@ def is_expected expect(subject) end + # @private + # should just be placed in private section, + # but Ruby issues warnings on private attributes. + # and expanding it to the equivalent method upsets Rubocop, + # b/c it should obviously be a reader + attr_reader :__memoized + private :__memoized + private # @private - def __memoized - @__memoized ||= {} + def initialize(*) + __init_memoized + super + end + + # @private + def __init_memoized + @__memoized = if RSpec.configuration.threadsafe? + ThreadsafeMemoized.new + else + NonThreadSafeMemoized.new + end + end + + # @private + class ThreadsafeMemoized + def initialize + @memoized = {} + @mutex = ReentrantMutex.new + end + + def fetch_or_store(key) + @memoized.fetch(key) do # only first access pays for synchronization + @mutex.synchronize do + @memoized.fetch(key) { @memoized[key] = yield } + end + end + end + end + + # @private + class NonThreadSafeMemoized + def initialize + @memoized = {} + end + + def fetch_or_store(key) + @memoized.fetch(key) { @memoized[key] = yield } + end end # Used internally to customize the behavior of the # memoized hash when used in a `before(:context)` hook. # # @private - class ContextHookMemoizedHash + class ContextHookMemoized def self.isolate_for_context_hook(example_group_instance) - hash = self + exploding_memoized = self example_group_instance.instance_exec do - @__memoized = hash + @__memoized = exploding_memoized begin yield ensure - @__memoized = nil + # This is doing a reset instead of just isolating for context hook. + # Really, this should set the old @__memoized back into place. + # + # Caller is the before and after context hooks + # which are both called from self.run + # I didn't look at why it made tests fail, maybe an object was getting reused in RSpec tests, + # if so, then that probably already works, and its the tests that are wrong. + __init_memoized end end end - def self.fetch(key, &_block) + def self.fetch_or_store(key, &_block) description = if key == :subject "subject" else @@ -206,9 +258,10 @@ module ClassMethods # maybe 3 declarations) in any given example group, but that can # quickly degrade with overuse. YMMV. # - # @note `let` uses an `||=` conditional that has the potential to - # behave in surprising ways in examples that spawn separate threads, - # though we have yet to see this in practice. You've been warned. + # @note `let` can be configured to be threadsafe or not. + # If it is threadsafe, it will take longer to access the value. + # If it is not threadsafe, it may behave in surprising ways in examples + # that spawn separate threads. Specify this on `RSpec.configure` # # @note Because `let` is designed to create state that is reset between # each example, and `before(:context)` is designed to setup state that @@ -237,9 +290,9 @@ def let(name, &block) # Apply the memoization. The method has been defined in an ancestor # module so we can use `super` here to get the value. if block.arity == 1 - define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } } + define_method(name) { __memoized.fetch_or_store(name) { super(RSpec.current_example, &nil) } } else - define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } } + define_method(name) { __memoized.fetch_or_store(name) { super(&nil) } } end end @@ -312,6 +365,11 @@ def let!(name, &block) # # When given a `name`, calling `super` in the block is not supported. # + # @note `subject` can be configured to be threadsafe or not. + # If it is threadsafe, it will take longer to access the value. + # If it is not threadsafe, it may behave in surprising ways in examples + # that spawn separate threads. Specify this on `RSpec.configure` + # # @param name [String,Symbol] used to define an accessor with an # intention revealing name # @param block defines the value to be returned by `subject` in examples @@ -443,6 +501,7 @@ def self.define_helpers_on(example_group) # Gets the named constant or yields. # On 1.8, const_defined? / const_get do not take into # account the inheritance hierarchy. + # :nocov: def self.get_constant_or_yield(example_group, name) if example_group.const_defined?(name) example_group.const_get(name) @@ -450,6 +509,7 @@ def self.get_constant_or_yield(example_group, name) yield end end + # :nocov: else # @private # diff --git a/lib/rspec/core/metadata.rb b/lib/rspec/core/metadata.rb index 6f71c4bf09..2c02034bd7 100644 --- a/lib/rspec/core/metadata.rb +++ b/lib/rspec/core/metadata.rb @@ -100,9 +100,8 @@ def self.deep_hash_dup(object) end # @private - def self.backtrace_from(block) - return caller unless block.respond_to?(:source_location) - [block.source_location.join(':')] + def self.id_from(metadata) + "#{metadata[:rerun_file_path]}[#{metadata[:scoped_id]}]" end # @private @@ -111,9 +110,10 @@ def self.backtrace_from(block) class HashPopulator attr_reader :metadata, :user_metadata, :description_args, :block - def initialize(metadata, user_metadata, description_args, block) + def initialize(metadata, user_metadata, index_provider, description_args, block) @metadata = metadata @user_metadata = user_metadata + @index_provider = index_provider @description_args = description_args @block = block end @@ -151,6 +151,8 @@ def populate_location_attributes metadata[:line_number] = line_number.to_i metadata[:location] = "#{relative_file_path}:#{line_number}" metadata[:absolute_file_path] = File.expand_path(relative_file_path) + metadata[:rerun_file_path] ||= relative_file_path + metadata[:scoped_id] = build_scoped_id_for(relative_file_path) end def file_path_and_line_number_from(backtrace) @@ -173,6 +175,12 @@ def build_description_from(parent_description=nil, my_description=nil) (parent_description.to_s + separator) << my_description.to_s end + def build_scoped_id_for(file_path) + index = @index_provider.call(file_path).to_s + parent_scoped_id = metadata.fetch(:scoped_id) { return index } + "#{parent_scoped_id}:#{index}" + end + def ensure_valid_user_keys RESERVED_KEYS.each do |key| next unless user_metadata.key?(key) @@ -196,7 +204,7 @@ def ensure_valid_user_keys # @private class ExampleHash < HashPopulator - def self.create(group_metadata, user_metadata, description, block) + def self.create(group_metadata, user_metadata, index_provider, description, block) example_metadata = group_metadata.dup group_metadata = Hash.new(&ExampleGroupHash.backwards_compatibility_default_proc do |hash| hash[:parent_example_group] @@ -208,7 +216,7 @@ def self.create(group_metadata, user_metadata, description, block) example_metadata.delete(:parent_example_group) description_args = description.nil? ? [] : [description] - hash = new(example_metadata, user_metadata, description_args, block) + hash = new(example_metadata, user_metadata, index_provider, description_args, block) hash.populate hash.metadata end @@ -229,7 +237,7 @@ def full_description # @private class ExampleGroupHash < HashPopulator - def self.create(parent_group_metadata, user_metadata, *args, &block) + def self.create(parent_group_metadata, user_metadata, example_group_index, *args, &block) group_metadata = hash_with_backwards_compatibility_default_proc if parent_group_metadata @@ -237,7 +245,7 @@ def self.create(parent_group_metadata, user_metadata, *args, &block) group_metadata[:parent_example_group] = parent_group_metadata end - hash = new(group_metadata, user_metadata, args, block) + hash = new(group_metadata, user_metadata, example_group_index, args, block) hash.populate hash.metadata end @@ -262,7 +270,7 @@ def self.backwards_compatibility_default_proc(&example_group_selector) # that take a metadata hash, and MetadataFilter sets this thread # local to silence the warning here since it would be so # confusing. - unless RSpec.thread_local_metadata[:silence_metadata_example_group_deprecations] + unless RSpec::Support.thread_local_data[:silence_metadata_example_group_deprecations] RSpec.deprecate("The `:example_group` key in an example group's metadata hash", :replacement => "the example group's hash directly for the " \ "computed keys and `:parent_example_group` to access the parent " \ @@ -308,15 +316,21 @@ def full_description # @private RESERVED_KEYS = [ :description, + :description_args, + :described_class, :example_group, :parent_example_group, :execution_result, + :last_run_status, :file_path, :absolute_file_path, + :rerun_file_path, :full_description, :line_number, :location, - :block + :scoped_id, + :block, + :shared_group_inclusion_backtrace ] end diff --git a/lib/rspec/core/metadata_filter.rb b/lib/rspec/core/metadata_filter.rb index ffe8c86258..d544931d8e 100644 --- a/lib/rspec/core/metadata_filter.rb +++ b/lib/rspec/core/metadata_filter.rb @@ -17,6 +17,7 @@ def filter_applies?(key, value, metadata) silence_metadata_example_group_deprecations do return filter_applies_to_any_value?(key, value, metadata) if Array === metadata[key] && !(Proc === value) return location_filter_applies?(value, metadata) if key == :locations + return id_filter_applies?(value, metadata) if key == :ids return filters_apply?(key, value, metadata) if Hash === value return false unless metadata.key?(key) @@ -42,9 +43,17 @@ def filter_applies_to_any_value?(key, value, metadata) metadata[key].any? { |v| filter_applies?(key, v, key => value) } end + def id_filter_applies?(rerun_paths_to_scoped_ids, metadata) + scoped_ids = rerun_paths_to_scoped_ids.fetch(metadata[:rerun_file_path]) { return false } + + Metadata.ascend(metadata).any? do |meta| + scoped_ids.include?(meta[:scoped_id]) + end + end + def location_filter_applies?(locations, metadata) line_numbers = example_group_declaration_lines(locations, metadata) - line_numbers.empty? || line_number_filter_applies?(line_numbers, metadata) + line_number_filter_applies?(line_numbers, metadata) end def line_number_filter_applies?(line_numbers, metadata) @@ -69,10 +78,10 @@ def filters_apply?(key, value, metadata) end def silence_metadata_example_group_deprecations - RSpec.thread_local_metadata[:silence_metadata_example_group_deprecations] = true + RSpec::Support.thread_local_data[:silence_metadata_example_group_deprecations] = true yield ensure - RSpec.thread_local_metadata.delete(:silence_metadata_example_group_deprecations) + RSpec::Support.thread_local_data.delete(:silence_metadata_example_group_deprecations) end end end @@ -118,6 +127,7 @@ def items_for(request_meta) end unless [].respond_to?(:each_with_object) # For 1.8.7 + # :nocov: undef items_for def items_for(request_meta) @items_and_filters.inject([]) do |to_return, (item, item_meta)| @@ -126,6 +136,7 @@ def items_for(request_meta) to_return end end + # :nocov: end end @@ -208,6 +219,7 @@ def proc_keys_from(metadata) end unless [].respond_to?(:each_with_object) # For 1.8.7 + # :nocov: undef proc_keys_from def proc_keys_from(metadata) metadata.inject([]) do |to_return, (key, value)| @@ -215,6 +227,7 @@ def proc_keys_from(metadata) to_return end end + # :nocov: end end end diff --git a/lib/rspec/core/mutex.rb b/lib/rspec/core/mutex.rb new file mode 100644 index 0000000000..57945ee1eb --- /dev/null +++ b/lib/rspec/core/mutex.rb @@ -0,0 +1,63 @@ +module RSpec + module Core + # On 1.8.7, it's in the stdlib. + # We don't want to load the stdlib, b/c this is a test tool, and can affect the test environment, + # causing tests to pass where they should fail. + # + # So we're transcribing/modifying it from https://github.com/ruby/ruby/blob/v1_8_7_374/lib/thread.rb#L56 + # Some methods we don't need are deleted. + # Anything I don't understand (there's quite a bit, actually) is left in. + # Some formating changes are made to appease the robot overlord: + # https://travis-ci.org/rspec/rspec-core/jobs/54410874 + # @private + class Mutex + def initialize + @waiting = [] + @locked = false + @waiting.taint + taint + end + + # @private + def lock + while Thread.critical = true && @locked + @waiting.push Thread.current + Thread.stop + end + @locked = true + Thread.critical = false + self + end + + # @private + def unlock + return unless @locked + Thread.critical = true + @locked = false + begin + t = @waiting.shift + t.wakeup if t + rescue ThreadError + retry + end + Thread.critical = false + begin + t.run if t + rescue ThreadError + :noop + end + self + end + + # @private + def synchronize + lock + begin + yield + ensure + unlock + end + end + end unless defined?(::RSpec::Core::Mutex) # Avoid warnings for library wide checks spec + end +end diff --git a/lib/rspec/core/notifications.rb b/lib/rspec/core/notifications.rb index 50355d2077..25f0faae90 100644 --- a/lib/rspec/core/notifications.rb +++ b/lib/rspec/core/notifications.rb @@ -1,4 +1,6 @@ +RSpec::Support.require_rspec_core "formatters/exception_presenter" RSpec::Support.require_rspec_core "formatters/helpers" +RSpec::Support.require_rspec_core "shell_escape" RSpec::Support.require_rspec_support "encoded_string" module RSpec::Core @@ -38,17 +40,19 @@ class ExampleNotification def self.for(example) execution_result = example.execution_result - if execution_result.pending_fixed? - PendingExampleFixedNotification.new(example) - elsif execution_result.example_skipped? - SkippedExampleNotification.new(example) - elsif execution_result.status == :pending - PendingExampleFailedAsExpectedNotification.new(example) - elsif execution_result.status == :failed - FailedExampleNotification.new(example) - else - new(example) - end + return SkippedExampleNotification.new(example) if execution_result.example_skipped? + return new(example) unless execution_result.status == :pending || execution_result.status == :failed + + klass = if execution_result.pending_fixed? + PendingExampleFixedNotification + elsif execution_result.status == :pending + PendingExampleFailedAsExpectedNotification + else + FailedExampleNotification + end + + exception_presenter = Formatters::ExceptionPresenter::Factory.new(example).build + klass.new(example, exception_presenter) end private_class_method :new @@ -135,7 +139,8 @@ def format_examples(examples) end # The `FailedExampleNotification` extends `ExampleNotification` with - # things useful for failed specs. + # things useful for examples that have failure info -- typically a + # failed or pending spec. # # @example # def example_failed(notification) @@ -151,19 +156,19 @@ class FailedExampleNotification < ExampleNotification # @return [Exception] The example failure def exception - example.execution_result.exception + @exception_presenter.exception end # @return [String] The example description def description - example.full_description + @exception_presenter.description end # Returns the message generated for this failure line by line. # # @return [Array] The example failure message def message_lines - add_shared_group_lines(failure_lines, NullColorizer) + @exception_presenter.message_lines end # Returns the message generated for this failure colorized line by line. @@ -171,16 +176,14 @@ def message_lines # @param colorizer [#wrap] An object to colorize the message_lines by # @return [Array] The example failure message colorized def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes) - add_shared_group_lines(failure_lines, colorizer).map do |line| - colorizer.wrap line, message_color - end + @exception_presenter.colorized_message_lines(colorizer) end # Returns the failures formatted backtrace. # # @return [Array] the examples backtrace lines def formatted_backtrace - backtrace_formatter.format_backtrace(exception.backtrace, example.metadata) + @exception_presenter.formatted_backtrace end # Returns the failures colorized formatted backtrace. @@ -188,170 +191,28 @@ def formatted_backtrace # @param colorizer [#wrap] An object to colorize the message_lines by # @return [Array] the examples colorized backtrace lines def colorized_formatted_backtrace(colorizer=::RSpec::Core::Formatters::ConsoleCodes) - formatted_backtrace.map do |backtrace_info| - colorizer.wrap "# #{backtrace_info}", RSpec.configuration.detail_color - end + @exception_presenter.colorized_formatted_backtrace(colorizer) end # @return [String] The failure information fully formatted in the way that # RSpec's built-in formatters emit. def fully_formatted(failure_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) - "\n #{failure_number}) #{description}\n#{formatted_message_and_backtrace(colorizer)}" - end - - private - - if String.method_defined?(:encoding) - def encoding_of(string) - string.encoding - end - else - def encoding_of(_string) - end - end - - def backtrace_formatter - RSpec.configuration.backtrace_formatter - end - - def exception_class_name - name = exception.class.name.to_s - name = "(anonymous error class)" if name == '' - name - end - - def failure_lines - @failure_lines ||= - begin - lines = ["Failure/Error: #{read_failed_line.strip}"] - lines << "#{exception_class_name}:" unless exception_class_name =~ /RSpec/ - exception.message.to_s.split("\n").each do |line| - lines << " #{line}" if exception.message - end - lines - end - end - - def add_shared_group_lines(lines, colorizer) - example.metadata[:shared_group_inclusion_backtrace].each do |frame| - lines << colorizer.wrap(frame.description, RSpec.configuration.default_color) - end - - lines - end - - def read_failed_line - matching_line = find_failed_line - unless matching_line - return "Unable to find matching line from backtrace" - end - - file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2] - - if File.exist?(file_path) - File.readlines(file_path)[line_number.to_i - 1] || - "Unable to find matching line in #{file_path}" - else - "Unable to find #{file_path} to read failed line" - end - rescue SecurityError - "Unable to read failed line" - end - - def find_failed_line - example_path = example.metadata[:absolute_file_path].downcase - exception.backtrace.find do |line| - next unless (line_path = line[/(.+?):(\d+)(|:\d+)/, 1]) - File.expand_path(line_path).downcase == example_path - end - end - - def formatted_message_and_backtrace(colorizer) - formatted = "" - - colorized_message_lines(colorizer).each do |line| - formatted << RSpec::Support::EncodedString.new(" #{line}\n", encoding_of(formatted)) - end - - colorized_formatted_backtrace(colorizer).each do |line| - formatted << RSpec::Support::EncodedString.new(" #{line}\n", encoding_of(formatted)) - end - - formatted - end - - def message_color - RSpec.configuration.failure_color - end - end - - # The `PendingExampleFixedNotification` extends `ExampleNotification` with - # things useful for specs that pass when they are expected to fail. - # - # @attr [RSpec::Core::Example] example the current example - # @see ExampleNotification - class PendingExampleFixedNotification < FailedExampleNotification - public_class_method :new - - # Returns the examples description. - # - # @return [String] The example description - def description - "#{example.full_description} FIXED" - end - - # Returns the message generated for this failure line by line. - # - # @return [Array] The example failure message - def message_lines - ["Expected pending '#{example.execution_result.pending_message}' to fail. No Error was raised."] + @exception_presenter.fully_formatted(failure_number, colorizer) end - # Returns the message generated for this failure colorized line by line. - # - # @param colorizer [#wrap] An object to colorize the message_lines by - # @return [Array] The example failure message colorized - def colorized_message_lines(colorizer=::RSpec::Core::Formatters::ConsoleCodes) - message_lines.map { |line| colorizer.wrap(line, RSpec.configuration.fixed_color) } - end - end - - # @private - module PendingExampleNotificationMethods private - def fully_formatted_header(pending_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) - colorizer.wrap("\n #{pending_number}) #{example.full_description}\n", :pending) << - colorizer.wrap(" # #{example.execution_result.pending_message}\n", :detail) + def initialize(example, exception_presenter=Formatters::ExceptionPresenter.new(example.execution_result.exception, example)) + @exception_presenter = exception_presenter + super(example) end end - # The `PendingExampleFailedAsExpectedNotification` extends `FailedExampleNotification` with - # things useful for pending specs that fail as expected. - # - # @attr [RSpec::Core::Example] example the current example - # @see ExampleNotification - class PendingExampleFailedAsExpectedNotification < FailedExampleNotification - include PendingExampleNotificationMethods - public_class_method :new - - # @return [Exception] The exception that occurred while the pending example was executed - def exception - example.execution_result.pending_exception - end - - # @return [String] The pending detail fully formatted in the way that - # RSpec's built-in formatters emit. - def fully_formatted(pending_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) - fully_formatted_header(pending_number, colorizer) << formatted_message_and_backtrace(colorizer) - end - - private + # @deprecated Use {FailedExampleNotification} instead. + class PendingExampleFixedNotification < FailedExampleNotification; end - def message_color - RSpec.configuration.pending_color - end - end + # @deprecated Use {FailedExampleNotification} instead. + class PendingExampleFailedAsExpectedNotification < FailedExampleNotification; end # The `SkippedExampleNotification` extends `ExampleNotification` with # things useful for specs that are skipped. @@ -359,14 +220,15 @@ def message_color # @attr [RSpec::Core::Example] example the current example # @see ExampleNotification class SkippedExampleNotification < ExampleNotification - include PendingExampleNotificationMethods public_class_method :new # @return [String] The pending detail fully formatted in the way that # RSpec's built-in formatters emit. def fully_formatted(pending_number, colorizer=::RSpec::Core::Formatters::ConsoleCodes) formatted_caller = RSpec.configuration.backtrace_formatter.backtrace_line(example.location) - fully_formatted_header(pending_number, colorizer) << colorizer.wrap(" # #{formatted_caller}\n", :detail) + colorizer.wrap("\n #{pending_number}) #{example.full_description}", :pending) << "\n " << + Formatters::ExceptionPresenter::PENDING_DETAIL_FORMATTER.call(example, colorizer) << + "\n" << colorizer.wrap(" # #{formatted_caller}\n", :detail) end end @@ -477,7 +339,7 @@ def colorized_totals_line(colorizer=::RSpec::Core::Formatters::ConsoleCodes) def colorized_rerun_commands(colorizer=::RSpec::Core::Formatters::ConsoleCodes) "\nFailed examples:\n\n" + failed_examples.map do |example| - colorizer.wrap("rspec #{example.rerun_argument}", RSpec.configuration.failure_color) + " " + + colorizer.wrap("rspec #{rerun_argument_for(example)}", RSpec.configuration.failure_color) + " " + colorizer.wrap("# #{example.full_description}", RSpec.configuration.detail_color) end.join("\n") end @@ -507,6 +369,28 @@ def fully_formatted(colorizer=::RSpec::Core::Formatters::ConsoleCodes) formatted end + + private + + include RSpec::Core::ShellEscape + + def rerun_argument_for(example) + location = example.location_rerun_argument + return location unless duplicate_rerun_locations.include?(location) + conditionally_quote(example.id) + end + + def duplicate_rerun_locations + @duplicate_rerun_locations ||= begin + locations = RSpec.world.all_examples.map(&:location_rerun_argument) + + Set.new.tap do |s| + locations.group_by { |l| l }.each do |l, ls| + s << l if ls.count > 1 + end + end + end + end end # The `ProfileNotification` holds information about the results of running a @@ -516,8 +400,16 @@ def fully_formatted(colorizer=::RSpec::Core::Formatters::ConsoleCodes) # @attr duration [Float] the time taken (in seconds) to run the suite # @attr examples [Array] the examples run # @attr number_of_examples [Fixnum] the number of examples to profile - ProfileNotification = Struct.new(:duration, :examples, :number_of_examples) + # @attr example_groups [Array] example groups run class ProfileNotification + def initialize(duration, examples, number_of_examples, example_groups) + @duration = duration + @examples = examples + @number_of_examples = number_of_examples + @example_groups = example_groups + end + attr_reader :duration, :examples, :number_of_examples + # @return [Array] the slowest examples def slowest_examples @slowest_examples ||= @@ -551,26 +443,15 @@ def slowest_groups private def calculate_slowest_groups - example_groups = {} - - examples.each do |example| - location = example.example_group.parent_groups.last.metadata[:location] - - location_hash = example_groups[location] ||= Hash.new(0) - location_hash[:total_time] += example.execution_result.run_time - location_hash[:count] += 1 - next if location_hash.key?(:description) - location_hash[:description] = example.example_group.top_level_description - end - # stop if we've only one example group - return {} if example_groups.keys.length <= 1 + return {} if @example_groups.keys.length <= 1 - example_groups.each_value do |hash| + @example_groups.each_value do |hash| hash[:average] = hash[:total_time].to_f / hash[:count] end - example_groups.sort_by { |_, hash| -hash[:average] }.first(number_of_examples) + groups = @example_groups.sort_by { |_, hash| -hash[:average] }.first(number_of_examples) + groups.map { |group, data| [group.location, data] } end end @@ -599,5 +480,19 @@ def self.from_hash(data) # currently require no information, but we may wish to extend in future. class NullNotification end + + # `CustomNotification` is used when sending custom events to formatters / + # other registered listeners, it creates attributes based on supplied hash + # of options. + class CustomNotification < Struct + # @param options [Hash] A hash of method / value pairs to create on this notification + # @return [CustomNotification] + # + # Build a custom notification based on the supplied option key / values. + def self.for(options={}) + return NullNotification if options.keys.empty? + new(*options.keys).new(*options.values) + end + end end end diff --git a/lib/rspec/core/option_parser.rb b/lib/rspec/core/option_parser.rb index 8ee17163c1..e9e278f742 100644 --- a/lib/rspec/core/option_parser.rb +++ b/lib/rspec/core/option_parser.rb @@ -4,23 +4,36 @@ module RSpec::Core # @private class Parser - def self.parse(args) - new.parse(args) + def self.parse(args, source=nil) + new(args).parse(source) end - def parse(args) - return {} if args.empty? + attr_reader :original_args + + def initialize(original_args) + @original_args = original_args + end + + def parse(source=nil) + return { :files_or_directories_to_run => [] } if original_args.empty? + args = original_args.dup options = args.delete('--tty') ? { :tty => true } : {} begin parser(options).parse!(args) rescue OptionParser::InvalidOption => e - abort "#{e.message}\n\nPlease use --help for a listing of valid options" + failure = e.message + failure << " (defined in #{source})" if source + abort "#{failure}\n\nPlease use --help for a listing of valid options" end + options[:files_or_directories_to_run] = args options end + private + + # rubocop:disable MethodLength def parser(options) OptionParser.new do |parser| parser.banner = "Usage: rspec [options] [files or directories]\n\n" @@ -51,12 +64,13 @@ def parser(options) options[:order] = "rand:#{seed}" end - parser.on('--fail-fast', 'Abort the run on first failure.') do |_o| - options[:fail_fast] = true + parser.on('--bisect[=verbose]', 'Repeatedly runs the suite in order to isolate the failures to the ', + ' smallest reproducible case.') do |argument| + bisect_and_exit(argument) end - parser.on('--no-fail-fast', 'Do not abort the run on first failure.') do |_o| - options[:fail_fast] = false + parser.on('--[no-]fail-fast', 'Abort the run on first failure.') do |value| + set_fail_fast(options, value) end parser.on('--failure-exit-code CODE', Integer, @@ -78,9 +92,7 @@ def parser(options) end parser.on('--init', 'Initialize your project with RSpec.') do |_cmd| - RSpec::Support.require_rspec_core "project_initializer" - ProjectInitializer.new.run - exit + initialize_project_and_exit end parser.separator("\n **** Output ****\n\n") @@ -143,14 +155,29 @@ def parser(options) **** Filtering/tags **** - In addition to the following options for selecting specific files, groups, - or examples, you can select a single example by appending the line number to + In addition to the following options for selecting specific files, groups, or + examples, you can select individual examples by appending the line number(s) to the filename: - rspec path/to/a_spec.rb:37 + rspec path/to/a_spec.rb:37:87 + + You can also pass example ids enclosed in square brackets: + + rspec path/to/a_spec.rb[1:5,1:6] # run the 5th and 6th examples/groups defined in the 1st group FILTERING + parser.on('--only-failures', "Filter to just the examples that failed the last time they ran.") do + configure_only_failures(options) + end + + parser.on("--next-failure", "Apply `--only-failures` and abort after one failure.", + " (Equivalent to `--only-failures --fail-fast --order defined`)") do + configure_only_failures(options) + set_fail_fast(options, true) + options[:order] ||= 'defined' + end + parser.on('-P', '--pattern PATTERN', 'Load files matching pattern (default: "spec/**/*_spec.rb").') do |o| options[:pattern] = o end @@ -175,18 +202,19 @@ def parser(options) name, value = tag.gsub(/^(~@|~|@)/, '').split(':', 2) name = name.to_sym - options[filter_type] ||= {} - options[filter_type][name] = case value - when nil then true # The default value for tags is true - when 'true' then true - when 'false' then false - when 'nil' then nil - when /^:/ then value[1..-1].to_sym - when /^\d+$/ then Integer(value) - when /^\d+.\d+$/ then Float(value) - else - value - end + parsed_value = case value + when nil then true # The default value for tags is true + when 'true' then true + when 'false' then false + when 'nil' then nil + when /^:/ then value[1..-1].to_sym + when /^\d+$/ then Integer(value) + when /^\d+.\d+$/ then Float(value) + else + value + end + + add_tag_filter(options, filter_type, name, parsed_value) end parser.on('--default-path PATH', 'Set the default path where RSpec looks for examples (can', @@ -197,8 +225,7 @@ def parser(options) parser.separator("\n **** Utility ****\n\n") parser.on('-v', '--version', 'Display the version.') do - puts RSpec::Core::Version::STRING - exit + print_version_and_exit end # These options would otherwise be confusing to users, so we forcibly @@ -210,9 +237,7 @@ def parser(options) invalid_options = %w[-d --I] parser.on_tail('-h', '--help', "You're looking at it.") do - # Removing the blank invalid options from the output. - puts parser.to_s.gsub(/^\s+(#{invalid_options.join('|')})\s*$\n/, '') - exit + print_help_and_exit(parser, invalid_options) end # This prevents usage of the invalid_options. @@ -224,5 +249,53 @@ def parser(options) end end + # rubocop:enable MethodLength + + def add_tag_filter(options, filter_type, tag_name, value=true) + (options[filter_type] ||= {})[tag_name] = value + end + + def set_fail_fast(options, value) + options[:fail_fast] = value + end + + def configure_only_failures(options) + options[:only_failures] = true + add_tag_filter(options, :inclusion_filter, :last_run_status, 'failed') + end + + def initialize_project_and_exit + RSpec::Support.require_rspec_core "project_initializer" + ProjectInitializer.new.run + exit + end + + def bisect_and_exit(argument) + RSpec::Support.require_rspec_core "bisect/coordinator" + + success = Bisect::Coordinator.bisect_with( + original_args, + RSpec.configuration, + bisect_formatter_for(argument) + ) + + exit(success ? 0 : 1) + end + + def bisect_formatter_for(argument) + return Formatters::BisectDebugFormatter if argument == "verbose" + Formatters::BisectProgressFormatter + end + + def print_version_and_exit + puts RSpec::Core::Version::STRING + exit + end + + def print_help_and_exit(parser, invalid_options) + # Removing the blank invalid options from the output. + puts parser.to_s.gsub(/^\s+(#{invalid_options.join('|')})\s*$\n/, '') + exit + end end end diff --git a/lib/rspec/core/ordering.rb b/lib/rspec/core/ordering.rb index c274cdcd35..f2284fb0d7 100644 --- a/lib/rspec/core/ordering.rb +++ b/lib/rspec/core/ordering.rb @@ -1,14 +1,5 @@ module RSpec module Core - if defined?(::Random) - # @private - RandomNumberGenerator = ::Random - else - RSpec::Support.require_rspec_core "backport_random" - # @private - RandomNumberGenerator = RSpec::Core::Backports::Random - end - # @private module Ordering # @private @@ -33,26 +24,38 @@ def used? def order(items) @used = true - rng = RandomNumberGenerator.new(@configuration.seed) - shuffle items, rng + + seed = @configuration.seed.to_s + items.sort_by { |item| jenkins_hash_digest(seed + item.id) } end - if RUBY_VERSION > '1.9.3' - def shuffle(list, rng) - list.shuffle(:random => rng) - end - else - def shuffle(list, rng) - shuffled = list.dup - shuffled.size.times do |i| - j = i + rng.rand(shuffled.size - i) - next if i == j - shuffled[i], shuffled[j] = shuffled[j], shuffled[i] - end - - shuffled + private + + # http://en.wikipedia.org/wiki/Jenkins_hash_function + # Jenkins provides a good distribution and is simpler than MD5. + # It's a bit slower than MD5 (primarily because `Digest::MD5` is + # implemented in C) but has the advantage of not requiring us + # to load another part of stdlib, which we try to minimize. + def jenkins_hash_digest(string) + hash = 0 + + string.each_byte do |byte| + hash += byte + hash &= MAX_32_BIT + hash += ((hash << 10) & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 6 end + + hash += ((hash << 3) & MAX_32_BIT) + hash &= MAX_32_BIT + hash ^= hash >> 11 + hash += ((hash << 15) & MAX_32_BIT) + hash &= MAX_32_BIT + hash end + + MAX_32_BIT = 4_294_967_295 end # @private diff --git a/lib/rspec/core/profiler.rb b/lib/rspec/core/profiler.rb new file mode 100644 index 0000000000..afe7731105 --- /dev/null +++ b/lib/rspec/core/profiler.rb @@ -0,0 +1,32 @@ +module RSpec + module Core + # @private + class Profiler + NOTIFICATIONS = [:example_group_started, :example_group_finished, :example_started] + + def initialize + @example_groups = Hash.new { |h, k| h[k] = { :count => 0 } } + end + + attr_reader :example_groups + + def example_group_started(notification) + return unless notification.group.top_level? + + @example_groups[notification.group][:start] = Time.now + @example_groups[notification.group][:description] = notification.group.top_level_description + end + + def example_group_finished(notification) + return unless notification.group.top_level? + + @example_groups[notification.group][:total_time] = Time.now - @example_groups[notification.group][:start] + end + + def example_started(notification) + group = notification.example.example_group.parent_groups.last + @example_groups[group][:count] += 1 + end + end + end +end diff --git a/lib/rspec/core/project_initializer/spec/spec_helper.rb b/lib/rspec/core/project_initializer/spec/spec_helper.rb index b598abee98..6839d5f9d2 100644 --- a/lib/rspec/core/project_initializer/spec/spec_helper.rb +++ b/lib/rspec/core/project_initializer/spec/spec_helper.rb @@ -50,10 +50,15 @@ config.filter_run :focus config.run_all_when_everything_filtered = true + # Allows RSpec to persist some state between runs in order to support + # the `--only-failures` and `--next-failure` CLI options. We recommend + # you configure your source control system to ignore this file. + config.example_status_persistence_file_path = "spec/examples.txt" + # Limits the available syntax to the non-monkey patched syntax that is # recommended. For more details, see: # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax - # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching config.disable_monkey_patching! diff --git a/lib/rspec/core/rake_task.rb b/lib/rspec/core/rake_task.rb index baf1c441d6..60308fd565 100644 --- a/lib/rspec/core/rake_task.rb +++ b/lib/rspec/core/rake_task.rb @@ -1,6 +1,7 @@ require 'rake' require 'rake/tasklib' require 'rspec/support/ruby_features' +require 'rspec/core/shell_escape' module RSpec module Core @@ -9,6 +10,7 @@ module Core # @see Rakefile class RakeTask < ::Rake::TaskLib include ::Rake::DSL if defined?(::Rake::DSL) + include RSpec::Core::ShellEscape # Default path to the RSpec executable. DEFAULT_RSPEC_PATH = File.expand_path('../../../../exe/rspec', __FILE__) @@ -63,16 +65,12 @@ def initialize(*args, &task_block) # @private def run_task(verbose) command = spec_command + puts command if verbose - begin - puts command if verbose - success = system(command) - rescue - puts failure_message if failure_message - end - - return unless fail_on_error && !success + return if system(command) + puts failure_message if failure_message + return unless fail_on_error $stderr.puts "#{command} failed" if verbose exit $?.exitstatus end @@ -126,18 +124,6 @@ def file_inclusion_specification end end - if RSpec::Support::OS.windows? - def escape(shell_command) - "'#{shell_command.gsub("'", "\'")}'" - end - else - require 'shellwords' - - def escape(shell_command) - shell_command.shellescape - end - end - def file_exclusion_specification " --exclude-pattern #{escape exclude_pattern}" if exclude_pattern end diff --git a/lib/rspec/core/reentrant_mutex.rb b/lib/rspec/core/reentrant_mutex.rb new file mode 100644 index 0000000000..c3065ec7a0 --- /dev/null +++ b/lib/rspec/core/reentrant_mutex.rb @@ -0,0 +1,52 @@ +module RSpec + module Core + # Allows a thread to lock out other threads from a critical section of code, + # while allowing the thread with the lock to reenter that section. + # + # Based on Monitor as of 2.2 - https://github.com/ruby/ruby/blob/eb7ddaa3a47bf48045d26c72eb0f263a53524ebc/lib/monitor.rb#L9 + # + # Depends on Mutex, but Mutex is only available as part of core since 1.9.1: + # exists - http://ruby-doc.org/core-1.9.1/Mutex.html + # dne - http://ruby-doc.org/core-1.9.0/Mutex.html + # + # @private + class ReentrantMutex + def initialize + @owner = nil + @count = 0 + @mutex = Mutex.new + end + + def synchronize + enter + yield + ensure + exit + end + + private + + def enter + @mutex.lock if @owner != Thread.current + @owner = Thread.current + @count += 1 + end + + def exit + @count -= 1 + return unless @count == 0 + @owner = nil + @mutex.unlock + end + end + + if defined? ::Mutex + # On 1.9 and up, this is in core, so we just use the real one + Mutex = ::Mutex + else # For 1.8.7 + # :nocov: + RSpec::Support.require_rspec_core "mutex" + # :nocov: + end + end +end diff --git a/lib/rspec/core/reporter.rb b/lib/rspec/core/reporter.rb index 0405889355..b396261999 100644 --- a/lib/rspec/core/reporter.rb +++ b/lib/rspec/core/reporter.rb @@ -2,6 +2,15 @@ module RSpec::Core # A reporter will send notifications to listeners, usually formatters for the # spec suite run. class Reporter + # @private + RSPEC_NOTIFICATIONS = Set.new( + [ + :close, :deprecation, :deprecation_summary, :dump_failures, :dump_pending, + :dump_profile, :dump_summary, :example_failed, :example_group_finished, + :example_group_started, :example_passed, :example_pending, :example_started, + :message, :seed, :start, :start_dump, :stop + ]) + def initialize(configuration) @configuration = configuration @listeners = Hash.new { |h, k| h[k] = Set.new } @@ -19,6 +28,13 @@ def reset @examples = [] @failed_examples = [] @pending_examples = [] + @profiler = Profiler.new if defined?(@profiler) + end + + # @private + def setup_profiler + @profiler = Profiler.new + register_listener @profiler, *Profiler::NOTIFICATIONS end # Registers a listener to a list of notifications. The reporter will send @@ -40,7 +56,6 @@ def registered_listeners(notification) @listeners[notification].to_a end - # @api # @overload report(count, &block) # @overload report(count, &block) # @param expected_example_count [Integer] the number of examples being run @@ -69,15 +84,30 @@ def report(expected_example_count) def start(expected_example_count, time=RSpec::Core::Time.now) @start = time @load_time = (@start - @configuration.start_time).to_f - notify :start, Notifications::StartNotification.new(expected_example_count, @load_time) notify :seed, Notifications::SeedNotification.new(@configuration.seed, seed_used?) + notify :start, Notifications::StartNotification.new(expected_example_count, @load_time) end - # @private + # @param message [#to_s] A message object to send to formatters + # + # Send a custom message to supporting formatters. def message(message) notify :message, Notifications::MessageNotification.new(message) end + # @param event [Symbol] Name of the custom event to trigger on formatters + # @param options [Hash] Hash of arguments to provide via `CustomNotification` + # + # Publish a custom event to supporting registered formatters. + # @see RSpec::Core::Notifications::CustomNotification + def publish(event, options={}) + if RSPEC_NOTIFICATIONS.include? event + raise "RSpec::Core::Reporter#publish is intended for sending custom " \ + "events not internal RSpec ones, please rename your custom event." + end + notify event, Notifications::CustomNotification.for(options) + end + # @private def example_group_started(group) notify :example_group_started, Notifications::GroupNotification.new(group) unless group.descendant_filtered_examples.empty? @@ -118,20 +148,28 @@ def deprecation(hash) # @private def finish - stop - notify :start_dump, Notifications::NullNotification - notify :dump_pending, Notifications::ExamplesNotification.new(self) - notify :dump_failures, Notifications::ExamplesNotification.new(self) - notify :deprecation_summary, Notifications::NullNotification - unless mute_profile_output? - notify :dump_profile, Notifications::ProfileNotification.new(@duration, @examples, - @configuration.profile_examples) + close_after do + stop + notify :start_dump, Notifications::NullNotification + notify :dump_pending, Notifications::ExamplesNotification.new(self) + notify :dump_failures, Notifications::ExamplesNotification.new(self) + notify :deprecation_summary, Notifications::NullNotification + unless mute_profile_output? + notify :dump_profile, Notifications::ProfileNotification.new(@duration, @examples, + @configuration.profile_examples, + @profiler.example_groups) + end + notify :dump_summary, Notifications::SummaryNotification.new(@duration, @examples, @failed_examples, + @pending_examples, @load_time) + notify :seed, Notifications::SeedNotification.new(@configuration.seed, seed_used?) end - notify :dump_summary, Notifications::SummaryNotification.new(@duration, @examples, @failed_examples, - @pending_examples, @load_time) - notify :seed, Notifications::SeedNotification.new(@configuration.seed, seed_used?) + end + + # @private + def close_after + yield ensure - notify :close, Notifications::NullNotification + close end # @private @@ -147,8 +185,19 @@ def notify(event, notification) end end + # @private + def abort_with(msg, exit_status) + message(msg) + close + exit!(exit_status) + end + private + def close + notify :close, Notifications::NullNotification + end + def mute_profile_output? # Don't print out profiled info if there are failures and `--fail-fast` is # used, it just clutters the output. @@ -163,10 +212,9 @@ def seed_used? # @private # # Used in place of a {Reporter} for situations where we don't want reporting output. class NullReporter - private - - def method_missing(*) + def self.method_missing(*) # ignore end + private_class_method :method_missing end end diff --git a/lib/rspec/core/runner.rb b/lib/rspec/core/runner.rb index af5612b92c..18dbc11d3f 100644 --- a/lib/rspec/core/runner.rb +++ b/lib/rspec/core/runner.rb @@ -17,20 +17,23 @@ def self.autorun return end - at_exit do - # Don't bother running any specs and just let the program terminate - # if we got here due to an unrescued exception (anything other than - # SystemExit, which is raised when somebody calls Kernel#exit). - next unless $!.nil? || $!.is_a?(SystemExit) - - # We got here because either the end of the program was reached or - # somebody called Kernel#exit. Run the specs and then override any - # existing exit status with RSpec's exit status if any specs failed. - invoke - end + at_exit { perform_at_exit } @installed_at_exit = true end + # @private + def self.perform_at_exit + # Don't bother running any specs and just let the program terminate + # if we got here due to an unrescued exception (anything other than + # SystemExit, which is raised when somebody calls Kernel#exit). + return unless $!.nil? || $!.is_a?(SystemExit) + + # We got here because either the end of the program was reached or + # somebody called Kernel#exit. Run the specs and then override any + # existing exit status with RSpec's exit status if any specs failed. + invoke + end + # Runs the suite of specs and exits the process with an appropriate exit # code. def self.invoke @@ -83,7 +86,9 @@ def initialize(options, configuration=RSpec.configuration, world=RSpec.world) # @param out [IO] output stream def run(err, out) setup(err, out) - run_specs(@world.ordered_example_groups) + run_specs(@world.ordered_example_groups).tap do + persist_example_statuses + end end # Wires together the various configuration objects and state holders. @@ -112,6 +117,19 @@ def run_specs(example_groups) end end + private + + def persist_example_statuses + return unless (path = @configuration.example_status_persistence_file_path) + + ExampleStatusPersister.persist(@world.all_examples, path) + rescue SystemCallError => e + RSpec.warning "Could not write example statuses to #{path} (configured as " \ + "`config.example_status_persistence_file_path`) due to a " \ + "system error: #{e.inspect}. Please check that the config " \ + "option is set to an accessible, valid file path", :call_site => nil + end + # @private def self.disable_autorun! @autorun_disabled = true @@ -144,8 +162,14 @@ def self.running_in_drb? # @private def self.trap_interrupt - trap('INT') do - exit!(1) if RSpec.world.wants_to_quit + trap('INT') { handle_interrupt } + end + + # @private + def self.handle_interrupt + if RSpec.world.wants_to_quit + exit!(1) + else RSpec.world.wants_to_quit = true STDERR.puts "\nRSpec is shutting down and will print the summary report... Interrupt again to force quit." end diff --git a/lib/rspec/core/set.rb b/lib/rspec/core/set.rb new file mode 100644 index 0000000000..359199ae53 --- /dev/null +++ b/lib/rspec/core/set.rb @@ -0,0 +1,49 @@ +module RSpec + module Core + # @private + # + # We use this to replace `::Set` so we can have the advantage of + # constant time key lookups for unique arrays but without the + # potential to pollute a developers environment with an extra + # piece of the stdlib. This helps to prevent false positive + # builds. + # + class Set + include Enumerable + + def initialize(array=[]) + @values = {} + merge(array) + end + + def empty? + @values.empty? + end + + def <<(key) + @values[key] = true + self + end + + def delete(key) + @values.delete(key) + end + + def each(&block) + @values.keys.each(&block) + self + end + + def include?(key) + @values.key?(key) + end + + def merge(values) + values.each do |key| + @values[key] = true + end + self + end + end + end +end diff --git a/lib/rspec/core/shared_example_group.rb b/lib/rspec/core/shared_example_group.rb index 4af32607cb..e1b7b86374 100644 --- a/lib/rspec/core/shared_example_group.rb +++ b/lib/rspec/core/shared_example_group.rb @@ -80,7 +80,7 @@ module SharedExampleGroup # @see ExampleGroup.include_context def shared_examples(name, *args, &block) top_level = self == ExampleGroup - if top_level && RSpec.thread_local_metadata[:in_example_group] + if top_level && RSpec::Support.thread_local_data[:in_example_group] raise "Creating isolated shared examples from within a context is " \ "not allowed. Remove `RSpec.` prefix or move this to a " \ "top-level scope." @@ -195,10 +195,12 @@ def formatted_location(block) if Proc.method_defined?(:source_location) def ensure_block_has_source_location(_block); end else # for 1.8.7 + # :nocov: def ensure_block_has_source_location(block) source_location = yield.split(':') block.extend Module.new { define_method(:source_location) { source_location } } end + # :nocov: end end end diff --git a/lib/rspec/core/shell_escape.rb b/lib/rspec/core/shell_escape.rb new file mode 100644 index 0000000000..46950cc78b --- /dev/null +++ b/lib/rspec/core/shell_escape.rb @@ -0,0 +1,49 @@ +module RSpec + module Core + # @private + # Deals with the fact that `shellwords` only works on POSIX systems. + module ShellEscape + module_function + + def quote(argument) + "'#{argument.gsub("'", "\\\\'")}'" + end + + if RSpec::Support::OS.windows? + # :nocov: + alias escape quote + # :nocov: + else + require 'shellwords' + + def escape(shell_command) + shell_command.shellescape + end + end + + # Known shells that require quoting: zsh, csh, tcsh. + # + # Feel free to add other shells to this list that are known to + # allow `rspec ./some_spec.rb[1:1]` syntax without quoting the id. + # + # @private + SHELLS_ALLOWING_UNQUOTED_IDS = %w[ bash ksh fish ] + + def conditionally_quote(id) + return id if shell_allows_unquoted_ids? + quote(id) + end + + def shell_allows_unquoted_ids? + # Note: ENV['SHELL'] isn't necessarily the shell the user is currently running. + # According to http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html: + # "This variable shall represent a pathname of the user's preferred command language interpreter." + # + # It's the best we can easily do, though. We err on the side of safety (quoting + # the id when not actually needed) so it's not a big deal if the user is actually + # using a different shell. + SHELLS_ALLOWING_UNQUOTED_IDS.include?(ENV['SHELL'].to_s.split('/').last) + end + end + end +end diff --git a/lib/rspec/core/version.rb b/lib/rspec/core/version.rb index 5fa13d465d..043568bdaf 100644 --- a/lib/rspec/core/version.rb +++ b/lib/rspec/core/version.rb @@ -3,7 +3,7 @@ module Core # Version information for RSpec Core. module Version # Current version of RSpec Core, in semantic versioning format. - STRING = '3.2.0' + STRING = '3.3.0' end end end diff --git a/lib/rspec/core/world.rb b/lib/rspec/core/world.rb index 307538eecc..ed15daa2cd 100644 --- a/lib/rspec/core/world.rb +++ b/lib/rspec/core/world.rb @@ -13,22 +13,12 @@ class World def initialize(configuration=RSpec.configuration) @configuration = configuration @example_groups = [] + @example_group_counts_by_spec_file = Hash.new(0) @filtered_examples = Hash.new do |hash, group| - hash[group] = begin - examples = group.examples.dup - examples = filter_manager.prune(examples) - examples.uniq! - examples - end + hash[group] = filter_manager.prune(group.examples) end end - # @private - # Used internally to clear remaining groups when fail_fast is set. - def clear_remaining_example_groups - example_groups.clear - end - # @api private # # Apply ordering strategy from configuration to example groups. @@ -55,9 +45,15 @@ def filter_manager # Register an example group. def register(example_group) example_groups << example_group + @example_group_counts_by_spec_file[example_group.metadata[:file_path]] += 1 example_group end + # @private + def num_example_groups_defined_in(file) + @example_group_counts_by_spec_file[file] + end + # @private def shared_example_group_registry @shared_example_group_registry ||= SharedExampleGroup::Registry.new @@ -81,6 +77,16 @@ def example_count(groups=example_groups) inject(0) { |a, e| a + e.filtered_examples.size } end + # @private + def all_example_groups + FlatMap.flat_map(example_groups) { |g| g.descendants } + end + + # @private + def all_examples + FlatMap.flat_map(all_example_groups) { |g| g.examples } + end + # @api private # # Find line number of previous declaration. @@ -99,6 +105,7 @@ def reporter # # Notify reporter of filters. def announce_filters + fail_if_config_and_cli_options_invalid filter_announcements = [] announce_inclusion_filter filter_announcements @@ -112,7 +119,7 @@ def announce_filters end end - if @configuration.run_all_when_everything_filtered? && example_count.zero? + if @configuration.run_all_when_everything_filtered? && example_count.zero? && !@configuration.only_failures? reporter.message("#{everything_filtered_message}; ignoring #{inclusion_filter.description}") filtered_examples.clear inclusion_filter.clear @@ -123,13 +130,7 @@ def announce_filters example_groups.clear if filter_manager.empty? reporter.message("No examples found.") - elsif exclusion_filter.empty? - message = everything_filtered_message - if @configuration.run_all_when_everything_filtered? - message << "; ignoring #{inclusion_filter.description}" - end - reporter.message(message) - elsif inclusion_filter.empty? + elsif exclusion_filter.empty? || inclusion_filter.empty? reporter.message(everything_filtered_message) end end @@ -162,6 +163,16 @@ def announce_exclusion_filter(announcements) def declaration_line_numbers @declaration_line_numbers ||= FlatMap.flat_map(example_groups, &:declaration_line_numbers) end + + def fail_if_config_and_cli_options_invalid + return unless @configuration.only_failures_but_not_configured? + + reporter.abort_with( + "\nTo use `--only-failures`, you must first set " \ + "`config.example_status_persistence_file_path`.", + 1 # exit code + ) + end end end end diff --git a/rspec-core.gemspec b/rspec-core.gemspec index 301dba5b3f..670603e80f 100644 --- a/rspec-core.gemspec +++ b/rspec-core.gemspec @@ -13,8 +13,6 @@ Gem::Specification.new do |s| s.summary = "rspec-core-#{RSpec::Core::Version::STRING}" s.description = "BDD for Ruby. RSpec runner and example groups." - s.rubyforge_project = "rspec" - s.files = `git ls-files -- lib/*`.split("\n") s.files += %w[README.md License.txt Changelog.md .yardopts .document] s.test_files = [] @@ -47,7 +45,8 @@ Gem::Specification.new do |s| s.add_development_dependency "nokogiri", (RUBY_VERSION < '1.9.3' ? "1.5.2" : "~> 1.5") s.add_development_dependency "coderay", "~> 1.0.9" - s.add_development_dependency "mocha", "~> 0.13.0" - s.add_development_dependency "rr", "~> 1.0.4" - s.add_development_dependency "flexmock", "~> 0.9.0" + s.add_development_dependency "mocha", "~> 0.13.0" + s.add_development_dependency "rr", "~> 1.0.4" + s.add_development_dependency "flexmock", "~> 0.9.0" + s.add_development_dependency "thread_order", "~> 1.1.0" end diff --git a/script/clone_all_rspec_repos b/script/clone_all_rspec_repos index f83d2e910f..853182818a 100755 --- a/script/clone_all_rspec_repos +++ b/script/clone_all_rspec_repos @@ -1,5 +1,5 @@ #!/bin/bash -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. set -e diff --git a/script/functions.sh b/script/functions.sh index a96a5c71b4..1f216818f6 100644 --- a/script/functions.sh +++ b/script/functions.sh @@ -1,12 +1,13 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" source $SCRIPT_DIR/travis_functions.sh source $SCRIPT_DIR/predicate_functions.sh -# idea taken from: http://blog.headius.com/2010/03/jruby-startup-time-tips.html -export JRUBY_OPTS="${JRUBY_OPTS} -X-C" # disable JIT since these processes are so short lived +# If JRUBY_OPTS isn't set, use these. +# see http://docs.travis-ci.com/user/ci-environment/ +export JRUBY_OPTS=${JRUBY_OPTS:-"--server -Xcompile.invokedynamic=false"} SPECS_HAVE_RUN_FILE=specs.out MAINTENANCE_BRANCH=`cat maintenance-branch` @@ -59,6 +60,7 @@ function run_specs_one_by_one { echo "Running each spec file, one-by-one..." for file in `find spec -iname '*_spec.rb'`; do + echo "Running $file" bin/rspec $file -b --format progress done } @@ -112,7 +114,7 @@ function check_documentation_coverage { } function check_style_and_lint { - echo "bin/rubucop lib" + echo "bin/rubocop lib" bin/rubocop lib } diff --git a/script/predicate_functions.sh b/script/predicate_functions.sh index fc5d372c50..f7e370b006 100644 --- a/script/predicate_functions.sh +++ b/script/predicate_functions.sh @@ -1,4 +1,4 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. function is_mri { diff --git a/script/rspec_with_simplecov b/script/rspec_with_simplecov index 5c78675675..8a76dca3d5 100755 --- a/script/rspec_with_simplecov +++ b/script/rspec_with_simplecov @@ -24,14 +24,14 @@ begin # Simplecov emits some ruby warnings when loaded, so silence them. old_verbose, $VERBOSE = $VERBOSE, false - unless ENV['NO_COVERAGE'] || RUBY_VERSION < '1.9.3' + unless ENV['NO_COVERAGE'] || RUBY_VERSION.to_f < 2.1 require 'simplecov' SimpleCov.start do add_filter "./bundle/" add_filter "./tmp/" add_filter "./spec/" - minimum_coverage(RUBY_PLATFORM == 'java' ? 94 : 97) + minimum_coverage(100) end end rescue LoadError diff --git a/script/run_build b/script/run_build index e1edcef3a9..0e381c4f31 100755 --- a/script/run_build +++ b/script/run_build @@ -1,5 +1,5 @@ #!/bin/bash -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. set -e diff --git a/script/travis_functions.sh b/script/travis_functions.sh index 77829b3638..d93883d19b 100644 --- a/script/travis_functions.sh +++ b/script/travis_functions.sh @@ -1,4 +1,4 @@ -# This file was generated on 2015-01-07T22:08:46-08:00 from the rspec-dev repo. +# This file was generated on 2015-05-05T17:56:25+10:00 from the rspec-dev repo. # DO NOT modify it by hand as your changes will get lost the next time it is generated. # Taken from: diff --git a/spec/integration/bisect_spec.rb b/spec/integration/bisect_spec.rb new file mode 100644 index 0000000000..6d673f3418 --- /dev/null +++ b/spec/integration/bisect_spec.rb @@ -0,0 +1,37 @@ +RSpec::Support.require_rspec_core "formatters/bisect_progress_formatter" + +module RSpec::Core + RSpec.describe "Bisect", :slow, :simulate_shell_allowing_unquoted_ids do + include FormatterSupport + + before do + skip "These specs do not consistently pass or fail on AppVeyor on Ruby 2.1+" + end if ENV['APPVEYOR'] && RUBY_VERSION.to_f > 2.0 + + def bisect(cli_args, expected_status=nil) + RSpec.configuration.output_stream = formatter_output + parser = Parser.new(cli_args + ["--bisect"]) + expect(parser).to receive(:exit).with(expected_status) if expected_status + + expect { + parser.parse + }.to avoid_outputting.to_stdout_from_any_process.and avoid_outputting.to_stderr_from_any_process + + normalize_durations(formatter_output.string) + end + + context "when a load-time problem occurs while running the suite" do + it 'surfaces the stdout and stderr output to the user' do + output = bisect(%w[spec/rspec/core/resources/fail_on_load_spec.rb_], 1) + expect(output).to include("Bisect failed!", "undefined method `contex'", "About to call misspelled method") + end + end + + context "when the spec ordering is inconsistent" do + it 'stops bisecting and surfaces the problem to the user' do + output = bisect(%W[spec/rspec/core/resources/inconsistently_ordered_specs.rb], 1) + expect(output).to include("Bisect failed!", "The example ordering is inconsistent") + end + end + end +end diff --git a/spec/integration/filtering_spec.rb b/spec/integration/filtering_spec.rb index 46feb427a7..d9a8a1791d 100644 --- a/spec/integration/filtering_spec.rb +++ b/spec/integration/filtering_spec.rb @@ -5,26 +5,27 @@ before { clean_current_dir } it 'prints a rerun command for shared examples in external files that works to rerun' do - write_file "spec/support/shared_examples.rb", """ - RSpec.shared_examples 'a failing example' do - example { expect(1).to eq(2) } + write_file "spec/support/shared_examples.rb", " + RSpec.shared_examples 'with a failing example' do + example { expect(1).to eq(2) } # failing + example { expect(2).to eq(2) } # passing end - """ + " - write_file "spec/host_group_spec.rb", """ + write_file "spec/host_group_spec.rb", " load File.expand_path('../support/shared_examples.rb', __FILE__) RSpec.describe 'A group with shared examples' do - include_examples 'a failing example' + include_examples 'with a failing example' end RSpec.describe 'A group with a passing example' do example { expect(1).to eq(1) } end - """ + " run_command "" - expect(last_cmd_stdout).to include("2 examples, 1 failure") + expect(last_cmd_stdout).to include("3 examples, 1 failure") run_rerun_command_for_failing_spec expect(last_cmd_stdout).to include("1 example, 1 failure") # There was originally a bug when doing it again... @@ -39,7 +40,7 @@ def run_rerun_command_for_failing_spec context "with a shared example containing a context in a separate file" do it "runs the example nested inside the shared" do - write_file_formatted 'spec/shared_example.rb', """ + write_file_formatted 'spec/shared_example.rb', " RSpec.shared_examples_for 'a shared example' do it 'succeeds' do end @@ -49,15 +50,15 @@ def run_rerun_command_for_failing_spec end end end - """ + " - write_file_formatted 'spec/simple_spec.rb', """ + write_file_formatted 'spec/simple_spec.rb', " require File.join(File.dirname(__FILE__), 'shared_example.rb') RSpec.describe 'top level' do it_behaves_like 'a shared example' end - """ + " run_command 'spec/simple_spec.rb:3 -fd' expect(last_cmd_stdout).to match(/2 examples, 0 failures/) @@ -66,7 +67,7 @@ def run_rerun_command_for_failing_spec context "passing a line-number filter" do it "trumps exclusions, except for :if/:unless (which are absolute exclusions)" do - write_file_formatted 'spec/a_spec.rb', """ + write_file_formatted 'spec/a_spec.rb', " RSpec.configure do |c| c.filter_run_excluding :slow end @@ -81,7 +82,7 @@ def run_rerun_command_for_failing_spec example('ex 4', :slow ) { } example('ex 5', :if => false) { } end - """ + " run_command "spec/a_spec.rb -fd" expect(last_cmd_stdout).to include("1 example, 0 failures", "ex 3").and exclude("ex 1", "ex 2", "ex 4", "ex 5") @@ -99,14 +100,14 @@ def run_rerun_command_for_failing_spec context "passing a line-number-filtered file and a non-filtered file" do it "applies the line number filtering only to the filtered file, running all specs in the non-filtered file except excluded ones" do - write_file_formatted "spec/file_1_spec.rb", """ + write_file_formatted "spec/file_1_spec.rb", " RSpec.describe 'File 1' do it('passes') { } it('fails') { fail } end - """ + " - write_file_formatted "spec/file_2_spec.rb", """ + write_file_formatted "spec/file_2_spec.rb", " RSpec.configure do |c| c.filter_run_excluding :exclude_me end @@ -116,11 +117,90 @@ def run_rerun_command_for_failing_spec it('passes') { } it('fails', :exclude_me) { fail } end - """ + " run_command "spec/file_1_spec.rb:2 spec/file_2_spec.rb -fd" expect(last_cmd_stdout).to match(/3 examples, 0 failures/) expect(last_cmd_stdout).not_to match(/fails/) end + + it 'applies command line tag filters only to files that lack a line number filter' do + write_file_formatted "spec/file_1_spec.rb", " + RSpec.describe 'File 1' do + it('is selected by line') { } + it('is not selected', :tag) { } + end + " + + write_file_formatted "spec/file_2_spec.rb", " + RSpec.describe 'File 2' do + it('is not selected') { } + it('is selected by tag', :tag) { } + end + " + + run_command "spec/file_1_spec.rb:2 spec/file_2_spec.rb --tag tag -fd" + expect(last_cmd_stdout).to include( + "2 examples, 0 failures", + "is selected by line", "is selected by tag" + ).and exclude("not selected") + end + end + + context "passing example ids at the command line" do + it "selects matching examples" do + write_file_formatted "spec/file_1_spec.rb", " + RSpec.describe 'File 1' do + 1.upto(3) do |i| + example('ex ' + i.to_s) { expect(i).to be_odd } + end + end + " + + write_file_formatted "spec/file_2_spec.rb", " + RSpec.describe 'File 2' do + 1.upto(3) do |i| + example('ex ' + i.to_s) { expect(i).to be_even } + end + end + " + + # Using the form that Metadata.relative_path returns... + run_command "./spec/file_1_spec.rb[1:1,1:3] ./spec/file_2_spec.rb[1:2]" + expect(last_cmd_stdout).to match(/3 examples, 0 failures/) + + # Using spaces between scoped ids, and quoting the whole thing... + run_command "'./spec/file_1_spec.rb[1:1, 1:3]' ./spec/file_2_spec.rb[1:2]" + expect(last_cmd_stdout).to match(/3 examples, 0 failures/) + + # Without the leading `.`... + run_command "spec/file_1_spec.rb[1:1,1:3] spec/file_2_spec.rb[1:2]" + expect(last_cmd_stdout).to match(/3 examples, 0 failures/) + + # Using absolute paths... + spec_root = in_current_dir { File.expand_path("spec") } + run_command "#{spec_root}/file_1_spec.rb[1:1,1:3] #{spec_root}/file_2_spec.rb[1:2]" + expect(last_cmd_stdout).to match(/3 examples, 0 failures/) + end + + it "selects matching example groups" do + write_file_formatted "spec/file_1_spec.rb", " + RSpec.describe 'Group 1' do + example { fail } + + context 'nested 1' do + it { } + it { } + end + + context 'nested 2' do + example { fail } + end + end + " + + run_command "./spec/file_1_spec.rb[1:2]" + expect(last_cmd_stdout).to match(/2 examples, 0 failures/) + end end end diff --git a/spec/integration/order_spec.rb b/spec/integration/order_spec.rb index 5d5921e136..a7bac40e68 100644 --- a/spec/integration/order_spec.rb +++ b/spec/integration/order_spec.rb @@ -4,7 +4,7 @@ include_context "aruba support" before :all do - write_file 'spec/simple_spec.rb', """ + write_file 'spec/simple_spec.rb', " RSpec.describe 'group 1' do specify('group 1 example 1') {} specify('group 1 example 2') {} @@ -15,9 +15,9 @@ specify('group 1-1 example 3') {} end end - """ + " - write_file 'spec/simple_spec2.rb', """ + write_file 'spec/simple_spec2.rb', " RSpec.describe 'group 2' do specify('group 2 example 1') {} specify('group 2 example 2') {} @@ -28,9 +28,9 @@ specify('group 2-1 example 3') {} end end - """ + " - write_file 'spec/order_spec.rb', """ + write_file 'spec/order_spec.rb', " RSpec.describe 'group 1' do specify('group 1 example 1') {} specify('group 1 example 2') {} @@ -76,7 +76,7 @@ RSpec.describe('group 8') { specify('example') {} } RSpec.describe('group 9') { specify('example') {} } RSpec.describe('group 10') { specify('example') {} } - """ + " end describe '--order rand' do @@ -150,7 +150,7 @@ after { remove_file 'spec/custom_order_spec.rb' } before do - write_file 'spec/custom_order_spec.rb', """ + write_file 'spec/custom_order_spec.rb', " RSpec.configure do |config| config.register_ordering :global do |list| list.sort_by { |item| item.description } @@ -167,7 +167,7 @@ RSpec.describe 'group A' do specify('group A example 1') {} end - """ + " end it 'orders the groups and examples by the provided strategy' do diff --git a/spec/integration/persistence_failures_spec.rb b/spec/integration/persistence_failures_spec.rb new file mode 100644 index 0000000000..d26a78a8e6 --- /dev/null +++ b/spec/integration/persistence_failures_spec.rb @@ -0,0 +1,69 @@ +require 'support/aruba_support' + +RSpec.describe 'Persistence failures' do + include_context "aruba support" + before { clean_current_dir } + + context "when `config.example_status_persistence_file_path` is configured" do + context "to an invalid file path (e.g. spec/spec_helper.rb/examples.txt)" do + before do + write_file_formatted "spec/1_spec.rb", " + RSpec.configure do |c| + c.example_status_persistence_file_path = 'spec/1_spec.rb/examples.txt' + end + RSpec.describe { example { } } + " + end + + it 'emits a helpful warning to the user, indicating we cannot write to it, and still runs the spec suite' do + run_command "spec/1_spec.rb" + + expect(last_cmd_stderr).to include( + "WARNING: Could not write", + "spec/1_spec.rb/examples.txt", + "config.example_status_persistence_file_path", + "Errno:" + ) + expect(last_cmd_stdout).to include("1 example") + end + end + + context "to a file path for which we lack permissions" do + before do + write_file_formatted "spec/1_spec.rb", " + RSpec.configure do |c| + c.example_status_persistence_file_path = 'spec/examples.txt' + end + RSpec.describe { example { } } + " + + write_file_formatted "spec/examples.txt", "" + in_current_dir do + FileUtils.chmod 0000, "spec/examples.txt" + end + end + + + it 'emits a helpful warning to the user, indicating we cannot read from it, and still runs the spec suite' do + run_command "spec/1_spec.rb" + + expected_snippets = [ + "WARNING: Could not read", + "spec/examples.txt", + "config.example_status_persistence_file_path", + "Errno:" + ] + + if RSpec::Support::OS.windows? + # Not sure why, but on windows it doesn't trigger the read error, it + # triggers a write error instead. The important thing is that whatever + # system error occurs is reported accurately. + expected_snippets[0] = "WARNING: Could not write" + end + + expect(last_cmd_stderr).to include(*expected_snippets) + expect(last_cmd_stdout).to include("1 example") + end + end + end +end diff --git a/spec/rspec/core/aggregate_failures_spec.rb b/spec/rspec/core/aggregate_failures_spec.rb new file mode 100644 index 0000000000..df37c15401 --- /dev/null +++ b/spec/rspec/core/aggregate_failures_spec.rb @@ -0,0 +1,163 @@ +RSpec.describe "Aggregating failures" do + shared_examples_for "failure aggregation" do |exception_attribute, example_meta| + context "via the `aggregate_failures` method" do + context 'when the example has an expectation failure, plus an `after` hook and an `around` hook failure' do + it 'presents a flat list of three failures' do + ex = nil + + RSpec.describe do + ex = example "ex", example_meta do + aggregate_failures { expect(1).to be_even } + end + after { raise "after" } + around { |example| example.run; raise "around" } + end.run + + expect(ex.execution_result.__send__(exception_attribute)).to have_attributes( + :all_exceptions => [ + an_object_having_attributes(:message => 'expected `1.even?` to return true, got false'), + an_object_having_attributes(:message => 'after'), + an_object_having_attributes(:message => 'around') + ] + ) + end + end + + context 'when the example has multiple expectation failures, plus an `after` hook and an `around` hook failure' do + it 'nests the expectation failures so that they can be labeled with the aggregation block label' do + ex = nil + + RSpec.describe do + ex = example "ex", example_meta do + aggregate_failures do + expect(1).to be_even + expect(2).to be_odd + end + end + after { raise "after" } + around { |example| example.run; raise "around" } + end.run + + exception = ex.execution_result.__send__(exception_attribute) + + expect(exception).to have_attributes( + :all_exceptions => [ + an_object_having_attributes(:class => RSpec::Expectations::MultipleExpectationsNotMetError), + an_object_having_attributes(:message => 'after'), + an_object_having_attributes(:message => 'around') + ] + ) + + expect(exception.all_exceptions.first.all_exceptions).to match [ + an_object_having_attributes(:message => 'expected `1.even?` to return true, got false'), + an_object_having_attributes(:message => 'expected `2.odd?` to return true, got false') + ] + end + end + end + + context "via `:aggregate_failures` metadata" do + it 'applies `aggregate_failures` to examples or groups tagged with `:aggregate_failures`' do + ex = nil + + RSpec.describe "Aggregate failures", :aggregate_failures do + ex = it "has multiple failures", example_meta do + expect(1).to be_even + expect(2).to be_odd + end + end.run + + expect(ex.execution_result).not_to be_pending_fixed + expect(ex.execution_result.status).to eq(:pending) if example_meta.key?(:pending) + expect(ex.execution_result.__send__(exception_attribute)).to have_attributes( + :all_exceptions => [ + an_object_having_attributes(:message => 'expected `1.even?` to return true, got false'), + an_object_having_attributes(:message => 'expected `2.odd?` to return true, got false') + ] + ) + end + + context 'when the example has an exception, plus another error' do + it 'reports it as a multiple exception error' do + ex = nil + + RSpec.describe "Aggregate failures", :aggregate_failures do + ex = example "fail and raise", example_meta do + expect(1).to be_even + boom + end + end.run + + expect(ex.execution_result.__send__(exception_attribute)).to have_attributes( + :all_exceptions => [ + an_object_having_attributes(:message => 'expected `1.even?` to return true, got false'), + an_object_having_attributes(:class => NameError, :message => /boom/) + ] + ) + end + end + + context 'when the example has multiple exceptions, plus another error' do + it 'reports it as a flat multiple exception error' do + ex = nil + + RSpec.describe "Aggregate failures", :aggregate_failures do + ex = example "fail and raise", example_meta do + expect(1).to be_even + expect(2).to be_odd + boom + end + end.run + + expect(ex.execution_result.__send__(exception_attribute)).to have_attributes( + :all_exceptions => [ + an_object_having_attributes(:message => 'expected `1.even?` to return true, got false'), + an_object_having_attributes(:message => 'expected `2.odd?` to return true, got false'), + an_object_having_attributes(:class => NameError, :message => /boom/) + ] + ) + end + end + end + end + + context "for a non-pending example" do + include_examples "failure aggregation", :exception, {} + + it 'does not interfere with other `around` hooks' do + events = [] + + RSpec.describe "Outer" do + around do |ex| + events << :outer_before + ex.run + events << :outer_after + end + + context "aggregating failures", :aggregate_failures do + context "inner" do + around do |ex| + events << :inner_before + ex.run + events << :inner_after + end + + it "has multiple failures" do + events << :example_before + expect(1).to be_even + expect(2).to be_odd + events << :example_after + end + end + end + end.run + + expect(events).to eq([:outer_before, :inner_before, :example_before, + :example_after, :inner_after, :outer_after]) + end + end + + context "for a pending example" do + include_examples "failure aggregation", :pending_exception, :pending => true + end +end diff --git a/spec/rspec/core/backtrace_formatter_spec.rb b/spec/rspec/core/backtrace_formatter_spec.rb index 133f121719..ba81f4f86f 100644 --- a/spec/rspec/core/backtrace_formatter_spec.rb +++ b/spec/rspec/core/backtrace_formatter_spec.rb @@ -101,7 +101,7 @@ def make_backtrace_formatter(exclusion_patterns=nil, inclusion_patterns=nil) end describe "#format_backtrace" do - it "excludes lines from rspec libs by default", :unless => RSpec::Support::OS.windows? do + it "excludes lines from rspec libs by default" do backtrace = [ "/path/to/rspec-expectations/lib/rspec/expectations/foo.rb:37", "/path/to/rspec-expectations/lib/rspec/matchers/foo.rb:37", @@ -113,18 +113,6 @@ def make_backtrace_formatter(exclusion_patterns=nil, inclusion_patterns=nil) expect(BacktraceFormatter.new.format_backtrace(backtrace)).to eq(["./my_spec.rb:5"]) end - it "excludes lines from rspec libs by default", :failing_on_appveyor, :if => RSpec::Support::OS.windows? do - backtrace = [ - "\\path\\to\\rspec-expectations\\lib\\rspec\\expectations\\foo.rb:37", - "\\path\\to\\rspec-expectations\\lib\\rspec\\matchers\\foo.rb:37", - ".\\my_spec.rb:5", - "\\path\\to\\rspec-mocks\\lib\\rspec\\mocks\\foo.rb:37", - "\\path\\to\\rspec-core\\lib\\rspec\\core\\foo.rb:37" - ] - - expect(BacktraceFormatter.new.format_backtrace(backtrace)).to eq([".\\my_spec.rb:5"]) - end - context "when every line is filtered out" do let(:backtrace) do [ @@ -144,6 +132,13 @@ def make_backtrace_formatter(exclusion_patterns=nil, inclusion_patterns=nil) end end + describe "an empty backtrace" do + it "does not add the explanatory message about backtrace filtering" do + formatter = BacktraceFormatter.new + expect(formatter.format_backtrace([])).to eq([]) + end + end + context "when rspec is installed in the current working directory" do it "excludes lines from rspec libs by default", :unless => RSpec::Support::OS.windows? do backtrace = [ @@ -218,7 +213,8 @@ def make_backtrace_formatter(exclusion_patterns=nil, inclusion_patterns=nil) end it "deals gracefully with a security error" do - safely do + Metadata.instance_eval { @relative_path_regex = nil } + with_safe_set_to_level_that_triggers_security_errors do self.formatter.__send__(:backtrace_line, __FILE__) # on some rubies, this doesn't raise a SecurityError; this test just # assures that if it *does* raise an error, the error is caught inside diff --git a/spec/rspec/core/bisect/coordinator_spec.rb b/spec/rspec/core/bisect/coordinator_spec.rb new file mode 100644 index 0000000000..1713b644c5 --- /dev/null +++ b/spec/rspec/core/bisect/coordinator_spec.rb @@ -0,0 +1,143 @@ +require 'rspec/core/bisect/coordinator' +require 'support/fake_bisect_runner' +require 'support/formatter_support' + +module RSpec::Core + RSpec.describe Bisect::Coordinator, :simulate_shell_allowing_unquoted_ids do + include FormatterSupport + + let(:fake_runner) do + FakeBisectRunner.new( + 1.upto(8).map { |i| "#{i}.rb[1:1]" }, + %w[ 2.rb[1:1] ], + { "5.rb[1:1]" => "4.rb[1:1]" } + ) + end + + def find_minimal_repro(output, formatter=Formatters::BisectProgressFormatter) + allow(Bisect::Server).to receive(:run).and_yield(instance_double(Bisect::Server)) + allow(Bisect::Runner).to receive(:new).and_return(fake_runner) + + RSpec.configuration.output_stream = output + Bisect::Coordinator.bisect_with([], RSpec.configuration, formatter) + ensure + RSpec.reset # so that RSpec.configuration.output_stream isn't closed + end + + it 'notifies the bisect progress formatter of progress and closes the output' do + tempfile = Tempfile.new("bisect") + output_file = File.open(tempfile.path, "w") + expect { find_minimal_repro(output_file) }.to change(output_file, :closed?).from(false).to(true) + output = normalize_durations(File.read(tempfile.path)).chomp + + expect(output).to eq(<<-EOS.gsub(/^\s+\|/, '')) + |Bisect started using options: "" + |Running suite to find failures... (n.nnnn seconds) + |Starting bisect with 2 failing examples and 6 non-failing examples. + | + |Round 1: searching for 3 non-failing examples (of 6) to ignore: .. (n.nnnn seconds) + |Round 2: searching for 2 non-failing examples (of 3) to ignore: . (n.nnnn seconds) + |Round 3: searching for 1 non-failing example (of 1) to ignore: . (n.nnnn seconds) + |Bisect complete! Reduced necessary non-failing examples from 6 to 1 in n.nnnn seconds. + | + |The minimal reproduction command is: + | rspec 2.rb[1:1] 4.rb[1:1] 5.rb[1:1] + EOS + end + + it 'can use the bisect debug formatter to get detailed progress' do + output = StringIO.new + find_minimal_repro(output, Formatters::BisectDebugFormatter) + output = normalize_durations(output.string) + + expect(output).to eq(<<-EOS.gsub(/^\s+\|/, '')) + |Bisect started using options: "" + |Running suite to find failures... (n.nnnn seconds) + | - Failing examples (2): + | - 2.rb[1:1] + | - 5.rb[1:1] + | - Non-failing examples (6): + | - 1.rb[1:1] + | - 3.rb[1:1] + | - 4.rb[1:1] + | - 6.rb[1:1] + | - 7.rb[1:1] + | - 8.rb[1:1] + | + |Round 1: searching for 3 non-failing examples (of 6) to ignore: + | - Running: rspec 2.rb[1:1] 5.rb[1:1] 6.rb[1:1] 7.rb[1:1] 8.rb[1:1] (n.nnnn seconds) + | - Running: rspec 1.rb[1:1] 2.rb[1:1] 3.rb[1:1] 4.rb[1:1] 5.rb[1:1] (n.nnnn seconds) + | - Examples we can safely ignore (3): + | - 6.rb[1:1] + | - 7.rb[1:1] + | - 8.rb[1:1] + | - Remaining non-failing examples (3): + | - 1.rb[1:1] + | - 3.rb[1:1] + | - 4.rb[1:1] + | - Round finished (n.nnnn seconds) + |Round 2: searching for 2 non-failing examples (of 3) to ignore: + | - Running: rspec 2.rb[1:1] 4.rb[1:1] 5.rb[1:1] (n.nnnn seconds) + | - Examples we can safely ignore (2): + | - 1.rb[1:1] + | - 3.rb[1:1] + | - Remaining non-failing examples (1): + | - 4.rb[1:1] + | - Round finished (n.nnnn seconds) + |Round 3: searching for 1 non-failing example (of 1) to ignore: + | - Running: rspec 2.rb[1:1] 5.rb[1:1] (n.nnnn seconds) + | - Round finished (n.nnnn seconds) + |Bisect complete! Reduced necessary non-failing examples from 6 to 1 in n.nnnn seconds. + | + |The minimal reproduction command is: + | rspec 2.rb[1:1] 4.rb[1:1] 5.rb[1:1] + EOS + end + + context "when the user aborst the bisect with ctrl-c" do + let(:aborting_formatter) do + Class.new(Formatters::BisectProgressFormatter) do + Formatters.register self + + def bisect_round_finished(notification) + return super unless notification.round == 2 + + Process.kill("INT", Process.pid) + # Process.kill is not a synchronous call, so to ensure the output + # below aborts at a deterministic place, we need to block here. + # The sleep will be interrupted by the signal once the OS sends it. + # For the most part, this is only needed on JRuby, but we saw + # the asynchronous behavior on an MRI 2.0 travis build as well. + sleep 5 + end + end + end + + it "prints the most minimal repro command it has found so far" do + output = StringIO.new + expect { + find_minimal_repro(output, aborting_formatter) + }.to raise_error(an_object_having_attributes( + :class => SystemExit, + :status => 1 + )) + + output = normalize_durations(output.string) + + expect(output).to eq(<<-EOS.gsub(/^\s+\|/, '')) + |Bisect started using options: "" + |Running suite to find failures... (n.nnnn seconds) + |Starting bisect with 2 failing examples and 6 non-failing examples. + | + |Round 1: searching for 3 non-failing examples (of 6) to ignore: .. (n.nnnn seconds) + |Round 2: searching for 2 non-failing examples (of 3) to ignore: . + | + |Bisect aborted! + | + |The most minimal reproduction command discovered so far is: + | rspec 2.rb[1:1] 4.rb[1:1] 5.rb[1:1] + EOS + end + end + end +end diff --git a/spec/rspec/core/bisect/example_minimizer_spec.rb b/spec/rspec/core/bisect/example_minimizer_spec.rb new file mode 100644 index 0000000000..14dbb08def --- /dev/null +++ b/spec/rspec/core/bisect/example_minimizer_spec.rb @@ -0,0 +1,54 @@ +require 'rspec/core/bisect/example_minimizer' +require 'rspec/core/formatters/bisect_formatter' +require 'rspec/core/bisect/server' +require 'support/fake_bisect_runner' + +module RSpec::Core + RSpec.describe Bisect::ExampleMinimizer do + let(:fake_runner) do + FakeBisectRunner.new( + %w[ ex_1 ex_2 ex_3 ex_4 ex_5 ex_6 ex_7 ex_8 ], + %w[ ex_2 ], + { "ex_5" => "ex_4" } + ) + end + + it 'repeatedly runs various subsets of the suite, removing examples that have no effect on the failing examples' do + minimizer = Bisect::ExampleMinimizer.new(fake_runner, RSpec::Core::NullReporter) + minimizer.find_minimal_repro + expect(minimizer.repro_command_for_currently_needed_ids).to eq("rspec ex_2 ex_4 ex_5") + end + + it 'ignores flapping examples that did not fail on the initial full run but fail on later runs' do + def fake_runner.run(ids) + super.tap do |results| + @run_count ||= 0 + if (@run_count += 1) > 1 + results.failed_example_ids << "ex_8" + end + end + end + + minimizer = Bisect::ExampleMinimizer.new(fake_runner, RSpec::Core::NullReporter) + minimizer.find_minimal_repro + expect(minimizer.repro_command_for_currently_needed_ids).to eq("rspec ex_2 ex_4 ex_5") + end + + it 'aborts early when no examples fail' do + minimizer = Bisect::ExampleMinimizer.new(FakeBisectRunner.new( + %w[ ex_1 ex_2 ], [], {} + ), RSpec::Core::NullReporter) + + expect { + minimizer.find_minimal_repro + }.to raise_error(RSpec::Core::Bisect::BisectFailedError, /No failures found/i) + end + + context "when the `repro_command_for_currently_needed_ids` is queried before it has sufficient information" do + it 'returns an explanation that will be printed when the bisect run is aborted immediately' do + minimizer = Bisect::ExampleMinimizer.new(FakeBisectRunner.new([], [], {}), RSpec::Core::NullReporter) + expect(minimizer.repro_command_for_currently_needed_ids).to include("Not yet enough information") + end + end + end +end diff --git a/spec/rspec/core/bisect/runner_spec.rb b/spec/rspec/core/bisect/runner_spec.rb new file mode 100644 index 0000000000..cec5102b05 --- /dev/null +++ b/spec/rspec/core/bisect/runner_spec.rb @@ -0,0 +1,243 @@ +require 'rspec/core/bisect/runner' +require 'rspec/core/formatters/bisect_formatter' + +module RSpec::Core + RSpec.describe Bisect::Runner do + let(:server) { instance_double("RSpec::Core::Bisect::Server", :drb_port => 1234) } + let(:runner) { described_class.new(server, original_cli_args) } + + describe "#run" do + let(:original_cli_args) { %w[ spec/1_spec.rb ] } + + it "passes the failed examples from the original run as the expected failures so the runs can abort early" do + original_results = Formatters::BisectFormatter::RunResults.new( + [], %w[ spec/failure_spec.rb[1:1] spec/failure_spec.rb[1:2] ] + ) + + expect(server).to receive(:capture_run_results). + with(no_args). + ordered. + and_return(original_results) + + expect(server).to receive(:capture_run_results). + with(original_results.failed_example_ids). + ordered + + runner.run(%w[ spec/1_spec.rb[1:1] spec/1_spec.rb[1:2] ]) + end + end + + describe "#command_for" do + def command_for(locations, options={}) + load_path = options.fetch(:load_path) { [] } + orig_load_path = $LOAD_PATH.dup + $LOAD_PATH.replace(load_path) + runner.command_for(locations) + ensure + $LOAD_PATH.replace(orig_load_path) + end + + let(:original_cli_args) { %w[ spec/unit -rfoo -Ibar --warnings --backtrace ] } + + it "includes the original CLI arg options" do + cmd = command_for(%w[ spec/1.rb spec/2.rb ]) + expect(cmd).to include("-rfoo -Ibar --warnings --backtrace") + end + + it 'replaces the locations from the original CLI args with the provided locations' do + cmd = command_for(%w[ spec/1.rb spec/2.rb ]) + expect(cmd).to match(%r{'?spec/1\.rb'? '?spec/2\.rb'?}).and exclude("spec/unit") + end + + it 'escapes locations' do + cmd = command_for(["path/with spaces/to/spec.rb"]) + if uses_quoting_for_escaping? + expect(cmd).to include("'path/with spaces/to/spec.rb'") + else + expect(cmd).to include('path/with\ spaces/to/spec.rb') + end + end + + it "includes an option for the server's DRB port" do + cmd = command_for([]) + expect(cmd).to include("--drb-port #{server.drb_port}") + end + + it "ignores an existing --drb-port option (since we use the server's port instead)" do + original_cli_args << "--drb-port" << "9999" + cmd = command_for([]) + expect(cmd).to include("--drb-port #{server.drb_port}").and exclude("9999") + expect(cmd.scan("--drb-port").count).to eq(1) + end + + %w[ --bisect --bisect=verbose --bisect=blah ].each do |value| + it "ignores a `#{value}` option since that would infinitely recurse" do + original_cli_args << value + cmd = command_for([]) + expect(cmd).to exclude(value) + end + end + + it 'uses the bisect formatter' do + cmd = command_for([]) + expect(cmd).to include("--format bisect") + end + + def expect_formatters_to_be_excluded + cmd = command_for([]) + expect(cmd).to include("--format bisect").and exclude( + "progress", "html", "--out", "specs.html", "-f ", "-o " + ) + expect(cmd.scan("--format").count).to eq(1) + end + + it 'excludes any --format and matching --out options passed in the original args' do + original_cli_args.concat %w[ --format progress --format html --out specs.html ] + expect_formatters_to_be_excluded + end + + it 'excludes any -f and matching -o options passed in the original args' do + original_cli_args.concat %w[ -f progress -f html -o specs.html ] + expect_formatters_to_be_excluded + end + + it 'excludes any -f and matching -o options passed in the original args' do + original_cli_args.concat %w[ -fprogress -fhtml -ospecs.html ] + expect_formatters_to_be_excluded + end + + it 'starts with the path to the current ruby executable' do + cmd = command_for([]) + expect(cmd).to start_with(File.join( + RbConfig::CONFIG['bindir'], + RbConfig::CONFIG['ruby_install_name'] + )) + end + + it 'includes the path to the rspec executable after the ruby executable' do + cmd = command_for([]) + expect(cmd).to first_include("ruby").then_include(RSpec::Core.path_to_executable) + end + + it 'escapes the rspec executable' do + allow(RSpec::Core).to receive(:path_to_executable).and_return("path/with spaces/rspec") + cmd = command_for([]) + + if uses_quoting_for_escaping? + expect(cmd).to include("'path/with spaces/rspec'") + else + expect(cmd).to include('path/with\ spaces/rspec') + end + end + + it 'includes the current load path as an option to `ruby`, not as an option to `rspec`' do + cmd = command_for([], :load_path => %W[ lp/foo lp/bar ]) + if uses_quoting_for_escaping? + expect(cmd).to first_include("-I'lp/foo':'lp/bar'").then_include(RSpec::Core.path_to_executable) + else + expect(cmd).to first_include("-Ilp/foo:lp/bar").then_include(RSpec::Core.path_to_executable) + end + end + + it 'escapes the load path entries' do + cmd = command_for([], :load_path => ['l p/foo', 'l p/bar' ]) + if uses_quoting_for_escaping? + expect(cmd).to first_include("-I'l p/foo':'l p/bar'").then_include(RSpec::Core.path_to_executable) + else + expect(cmd).to first_include('-Il\ p/foo:l\ p/bar').then_include(RSpec::Core.path_to_executable) + end + end + end + + describe "#repro_command_from", :simulate_shell_allowing_unquoted_ids do + let(:original_cli_args) { %w[ spec/unit --seed 1234 ] } + + def repro_command_from(ids) + runner.repro_command_from(ids) + end + + it 'starts with `rspec #{example_ids}`' do + cmd = repro_command_from(%w[ ./spec/unit/1_spec.rb[1:1] ./spec/unit/2_spec.rb[1:1] ]) + expect(cmd).to start_with("rspec ./spec/unit/1_spec.rb[1:1] ./spec/unit/2_spec.rb[1:1]") + end + + it 'includes the original CLI args but excludes the original CLI locations' do + cmd = repro_command_from(%w[ ./spec/unit/1_spec.rb[1:1] ./spec/unit/2_spec.rb[1:1] ]) + expect(cmd).to include("--seed 1234").and exclude("spec/unit ") + end + + it 'includes original options that `command_for` excludes' do + original_cli_args << "--format" << "progress" + expect(runner.command_for(%w[ ./foo.rb[1:1] ])).to exclude("--format progress") + expect(repro_command_from(%w[ ./foo.rb[1:1] ])).to include("--format progress") + end + + it 'groups multiple ids for the same file together' do + cmd = repro_command_from(%w[ ./spec/unit/1_spec.rb[1:1] ./spec/unit/1_spec.rb[1:2] ]) + expect(cmd).to include("./spec/unit/1_spec.rb[1:1,1:2]") + end + + it 'prints the files in alphabetical order' do + cmd = repro_command_from(%w[ ./spec/unit/2_spec.rb[1:1] ./spec/unit/1_spec.rb[1:1] ]) + expect(cmd).to include("./spec/unit/1_spec.rb[1:1] ./spec/unit/2_spec.rb[1:1]") + end + + it 'prints ids from the same file in sequential order' do + cmd = repro_command_from(%w[ + ./spec/unit/1_spec.rb[2:1] + ./spec/unit/1_spec.rb[1:2] + ./spec/unit/1_spec.rb[1:1] + ./spec/unit/1_spec.rb[1:10] + ./spec/unit/1_spec.rb[1:9] + ]) + + expect(cmd).to include("./spec/unit/1_spec.rb[1:1,1:2,1:9,1:10,2:1]") + end + + it 'does not include `--bisect` even though the original args do' do + original_cli_args << "--bisect" + expect(repro_command_from(%w[ ./foo.rb[1:1] ])).to exclude("bisect") + end + + it 'quotes the ids on a shell like ZSH that requires it' do + with_env_vars 'SHELL' => '/usr/local/bin/zsh' do + expect(repro_command_from(%w[ ./foo.rb[1:1] ])).to include("'./foo.rb[1:1]'") + end + end + end + + describe "#original_results" do + let(:original_cli_args) { %w[spec/unit] } + + open3_method = Open3.respond_to?(:capture2e) ? :capture2e : :popen3 + open3_method = :popen3 if RSpec::Support::Ruby.jruby? + + before do + allow(Open3).to receive(open3_method).and_return( + [double("Exit Status"), double("Stdout/err")] + ) + allow(server).to receive(:capture_run_results) do |&block| + block.call + "the results" + end + end + + it "runs the suite with the locations from the original CLI args" do + runner.original_results + expect(Open3).to have_received(open3_method).with(a_string_including("spec/unit")) + end + + it 'returns the run results' do + expect(runner.original_results).to eq("the results") + end + + it 'memoizes, since it is expensive to re-run the suite' do + expect(runner.original_results).to be(runner.original_results) + end + end + + def uses_quoting_for_escaping? + RSpec::Support::OS.windows? || RSpec::Support::Ruby.jruby? + end + end +end diff --git a/spec/rspec/core/bisect/server_spec.rb b/spec/rspec/core/bisect/server_spec.rb new file mode 100644 index 0000000000..03a18b8a64 --- /dev/null +++ b/spec/rspec/core/bisect/server_spec.rb @@ -0,0 +1,148 @@ +require 'rspec/core/bisect/server' +require 'support/formatter_support' + +module RSpec::Core + RSpec.describe Bisect::Server do + RSpec::Matchers.define :have_running_server do + match do |drb| + begin + drb.current_server.alive? + rescue DRb::DRbServerNotFound + false + end + end + end + + it 'always stops the server, even if an error occurs while yielding' do + skip "This test flaps on JRuby 1.8 mode for some reason" if RSpec::Support::Ruby.jruby? && RUBY_VERSION.to_f < 1.9 + + expect(DRb).not_to have_running_server + + expect { + Bisect::Server.run do + expect(DRb).to have_running_server + raise "boom" + end + }.to raise_error("boom") + + expect(DRb).not_to have_running_server + end + + context "when results are failed to be reported" do + let(:server) { Bisect::Server.new } + + it "raises an error with the output" do + expect { + server.capture_run_results { "the output" } + }.to raise_error(an_object_having_attributes( + :class => Bisect::BisectFailedError, + :message => a_string_including("Failed to get results", "the output") + )) + end + end + + context "when used in combination with the BisectFormatter", :slow do + include FormatterSupport + + attr_reader :server + + around do |ex| + Bisect::Server.run do |the_server| + @server = the_server + ex.run + end + end + + def run_formatter_specs + RSpec.configuration.drb_port = server.drb_port + run_example_specs_with_formatter("bisect") + end + + it 'receives suite results' do + results = server.capture_run_results do + run_formatter_specs + end + + expect(results).to have_attributes( + :all_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[3:1] + ./spec/rspec/core/resources/formatter_specs.rb[4:1] + ./spec/rspec/core/resources/formatter_specs.rb[5:1] + ./spec/rspec/core/resources/formatter_specs.rb[5:2] + ], + :failed_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[4:1] + ./spec/rspec/core/resources/formatter_specs.rb[5:1] + ./spec/rspec/core/resources/formatter_specs.rb[5:2] + ] + ) + end + + describe "aborting the run early" do + it "aborts as soon as the last expected failure finishes, since we don't care about what happens after that" do + expected_failures = %w[ + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[4:1] + ] + + results = server.capture_run_results(expected_failures) do + run_formatter_specs + end + + expect(results).to have_attributes( + :all_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[3:1] + ./spec/rspec/core/resources/formatter_specs.rb[4:1] + ], + :failed_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[4:1] + ] + ) + end + + it 'aborts after an expected failure passes instead, even when there are remaining failing examples' do + passing_example = "./spec/rspec/core/resources/formatter_specs.rb[3:1]" + later_failing_example = "./spec/rspec/core/resources/formatter_specs.rb[4:1]" + + results = server.capture_run_results([passing_example, later_failing_example]) do + run_formatter_specs + end + + expect(results).to have_attributes( + :all_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:1:1] + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ./spec/rspec/core/resources/formatter_specs.rb[3:1] + ], + :failed_example_ids => %w[ + ./spec/rspec/core/resources/formatter_specs.rb[2:2:1] + ] + ) + end + + it 'aborts after an expected failure is pending instead, even when there are remaining failing examples' do + pending_example = "./spec/rspec/core/resources/formatter_specs.rb[1:1]" + later_failing_example = "./spec/rspec/core/resources/formatter_specs.rb[4:1]" + + results = server.capture_run_results([pending_example, later_failing_example]) do + run_formatter_specs + end + + expect(results).to have_attributes( + :all_example_ids => %w[ ./spec/rspec/core/resources/formatter_specs.rb[1:1] ], + :failed_example_ids => %w[] + ) + end + end + end + end +end diff --git a/spec/rspec/core/bisect/subset_enumerator_spec.rb b/spec/rspec/core/bisect/subset_enumerator_spec.rb new file mode 100644 index 0000000000..7f612e1a98 --- /dev/null +++ b/spec/rspec/core/bisect/subset_enumerator_spec.rb @@ -0,0 +1,47 @@ +require 'rspec/core/bisect/subset_enumerator' + +module RSpec::Core + RSpec.describe Bisect::SubsetEnumerator do + def enum_for(ids) + Bisect::SubsetEnumerator.new(ids) + end + + it 'is enumerable' do + expect(enum_for([])).to be_an(Enumerable) + end + + it 'systematically enumerates each subset of the given size, starting off with disjoint sets' do + ids = %w[ 1 2 3 4 5 6 7 8 ] + enum = enum_for(ids) + combos = enum.to_a + expect(combos).to start_with([ + # start with each half... + %w[ 1 2 3 4 ], %w[ 5 6 7 8 ], + # then cut in 4ths and combine those in all the unseen combos... + %w[ 1 2 5 6 ], %w[ 1 2 7 8 ], + %w[ 3 4 5 6 ], %w[ 3 4 7 8 ], + # then cut in 8ths and do the same... + %w[ 1 2 3 5 ], %w[ 1 2 3 6 ], %w[ 1 2 3 7 ], %w[ 1 2 3 8 ], + %w[ 1 2 4 5 ], %w[ 1 2 4 6 ], %w[ 1 2 4 7 ], %w[ 1 2 4 8 ] + ]) + + # We don't care to specify the rest of the order, but we care that all combos were hit. + expect(combos).to match_array(ids.combination(4)) + end + + it 'works with a list size that is not a power of 2' do + ids = %w[ 1 2 3 4 5 6 7 ] + enum = enum_for(ids) + combos = enum.to_a + expect(combos).to start_with([ + %w[ 1 2 3 4 ], %w[ 5 6 7 ], + %w[ 1 2 5 6 ], %w[ 1 2 7 ], + %w[ 3 4 5 6 ], %w[ 3 4 7 ] + ]) + + # Would be better to do: expect(combos).to match_array(ids.combination(4)) + # ...but we include a few extra sets of 3 due to our algorithm. + expect(combos).to include(*ids.combination(4)) + end + end +end diff --git a/spec/rspec/core/configuration/only_failures_support_spec.rb b/spec/rspec/core/configuration/only_failures_support_spec.rb new file mode 100644 index 0000000000..095fdb318e --- /dev/null +++ b/spec/rspec/core/configuration/only_failures_support_spec.rb @@ -0,0 +1,199 @@ +module RSpec::Core + RSpec.describe Configuration, "--only-failures support" do + let(:config) { Configuration.new } + + def simulate_persisted_examples(*examples) + config.example_status_persistence_file_path = "examples.txt" + persister = class_double(ExampleStatusPersister).as_stubbed_const + + allow(persister).to receive(:load_from).with("examples.txt").and_return(examples.flatten) + end + + describe "#last_run_statuses" do + def last_run_statuses + config.last_run_statuses + end + + context "when `example_status_persistence_file_path` is configured" do + before do + simulate_persisted_examples( + { :example_id => "id_1", :status => "passed" }, + { :example_id => "id_2", :status => "failed" } + ) + end + + it 'gets the last run statuses from the ExampleStatusPersister' do + expect(last_run_statuses).to eq( + 'id_1' => 'passed', 'id_2' => 'failed' + ) + end + + it 'returns a memoized value' do + expect(last_run_statuses).to be(last_run_statuses) + end + + specify 'the hash returns `unknown` for unknown example ids for consistency' do + expect(last_run_statuses["foo"]).to eq(Configuration::UNKNOWN_STATUS) + expect(last_run_statuses["bar"]).to eq(Configuration::UNKNOWN_STATUS) + end + end + + context "when `example_status_persistence_file_path` is not configured" do + before do + config.example_status_persistence_file_path = nil + end + + it 'returns a memoized value' do + expect(last_run_statuses).to be(last_run_statuses) + end + + it 'returns a blank hash without attempting to load the persisted statuses' do + persister = class_double(ExampleStatusPersister).as_stubbed_const + expect(persister).not_to receive(:load_from) + + expect(last_run_statuses).to eq({}) + end + + specify 'the hash returns `unknown` for all ids for consistency' do + expect(last_run_statuses["foo"]).to eq(Configuration::UNKNOWN_STATUS) + expect(last_run_statuses["bar"]).to eq(Configuration::UNKNOWN_STATUS) + end + end + + def allows_value_to_change_when_updated + simulate_persisted_examples( + { :example_id => "id_1", :status => "passed" }, + { :example_id => "id_2", :status => "failed" } + ) + + config.example_status_persistence_file_path = nil + + expect { + yield + }.to change { last_run_statuses }.to('id_1' => 'passed', 'id_2' => 'failed') + end + + it 'allows the value to be updated when `example_status_persistence_file_path` is set after first access' do + allows_value_to_change_when_updated do + config.example_status_persistence_file_path = "examples.txt" + end + end + + it 'allows the value to be updated when `example_status_persistence_file_path` is forced after first access' do + allows_value_to_change_when_updated do + config.force(:example_status_persistence_file_path => "examples.txt") + end + end + end + + describe "#spec_files_with_failures" do + def spec_files_with_failures + config.spec_files_with_failures + end + + context "when `example_status_persistence_file_path` is configured" do + it 'returns a memoized array of unique spec files that contain failed exaples' do + simulate_persisted_examples( + { :example_id => "./spec_1.rb[1:1]", :status => "failed" }, + { :example_id => "./spec_1.rb[1:2]", :status => "failed" }, + { :example_id => "./spec_2.rb[1:2]", :status => "passed" }, + { :example_id => "./spec_3.rb[1:2]", :status => "pending" }, + { :example_id => "./spec_4.rb[1:2]", :status => "unknown" }, + { :example_id => "./spec_5.rb[1:2]", :status => "failed" } + ) + + expect(spec_files_with_failures).to( + be_an(Array) & + be(spec_files_with_failures) & + contain_exactly("./spec_1.rb", "./spec_5.rb") + ) + end + end + + context "when `example_status_persistence_file_path` is not configured" do + it "returns a memoized blank array" do + config.example_status_persistence_file_path = nil + + expect(spec_files_with_failures).to( + eq([]) & be(spec_files_with_failures) + ) + end + end + + def allows_value_to_change_when_updated + simulate_persisted_examples({ :example_id => "./spec_1.rb[1:1]", :status => "failed" }) + + config.example_status_persistence_file_path = nil + + expect { + yield + }.to change { spec_files_with_failures }.to(["./spec_1.rb"]) + end + + it 'allows the value to be updated when `example_status_persistence_file_path` is set after first access' do + allows_value_to_change_when_updated do + config.example_status_persistence_file_path = "examples.txt" + end + end + + it 'allows the value to be updated when `example_status_persistence_file_path` is forced after first access' do + allows_value_to_change_when_updated do + config.force(:example_status_persistence_file_path => "examples.txt") + end + end + end + + describe "#files_to_run, when `only_failures` is set" do + around do |ex| + handle_current_dir_change do + Dir.chdir("spec/rspec/core", &ex) + end + end + + let(:default_path) { "resources" } + let(:files_with_failures) { ["./resources/a_spec.rb"] } + let(:files_loaded_via_default_path) do + configuration = Configuration.new + configuration.default_path = default_path + configuration.files_or_directories_to_run = [] + configuration.files_to_run + end + + before do + expect(files_loaded_via_default_path).not_to eq(files_with_failures) + config.default_path = default_path + + simulate_persisted_examples(files_with_failures.map do |file| + { :example_id => "#{file}[1:1]", :status => "failed" } + end) + + config.force(:only_failures => true) + end + + context "and no explicit paths have been set" do + it 'loads only the files that have failures' do + config.files_or_directories_to_run = [] + expect(config.files_to_run).to eq(files_with_failures) + end + + it 'loads the default path if there are no files with failures' do + simulate_persisted_examples([]) + config.files_or_directories_to_run = [] + expect(config.files_to_run).to eq(files_loaded_via_default_path) + end + end + + context "and a path has been set" do + it "loads the intersection of files matching the path and files with failures" do + config.files_or_directories_to_run = ["resources"] + expect(config.files_to_run).to eq(files_with_failures) + end + + it "loads all files matching the path when there are no intersecting files" do + config.files_or_directories_to_run = ["resources/acceptance"] + expect(config.files_to_run).to contain_files("resources/acceptance/foo_spec.rb") + end + end + end + end +end diff --git a/spec/rspec/core/configuration_options_spec.rb b/spec/rspec/core/configuration_options_spec.rb index f0a3794f36..d2119d31c9 100644 --- a/spec/rspec/core/configuration_options_spec.rb +++ b/spec/rspec/core/configuration_options_spec.rb @@ -106,6 +106,13 @@ opts.configure(config) end + it 'configures `only_failures` before `files_or_directories_to_run` since it affects loaded files' do + opts = config_options_object(*%w[ --only-failures ]) + expect(config).to receive(:force).with(:only_failures => true).ordered + expect(config).to receive(:files_or_directories_to_run=).ordered + opts.configure(config) + end + { "pattern" => :pattern, "exclude-pattern" => :exclude_pattern }.each do |flag, attr| it "sets #{attr} before `requires` so users can check `files_to_run` in a `spec_helper` loaded by `--require`" do opts = config_options_object(*%W[--require spec_helpe --#{flag} **/*.spec]) @@ -168,6 +175,18 @@ opts.configure(config) end end + + %w[ --only-failures --next-failure ].each do |option| + describe option do + it "changes `config.only_failures?` to true" do + opts = config_options_object(option) + + expect { + opts.configure(config) + }.to change(config, :only_failures?).from(a_falsey_value).to(true) + end + end + end end describe "-c, --color, and --colour" do @@ -348,6 +367,54 @@ end end + describe "invalid options" do + def expect_parsing_to_fail_mentioning_source(source, options=[]) + expect { + parse_options(*options) + }.to raise_error(SystemExit).and output(a_string_including( + "invalid option: --default_path (defined in #{source})", + "Please use --help for a listing of valid options" + )).to_stderr + end + + %w[ ~/.rspec ./.rspec ./.rspec-local ].each do |file_name| + context "defined in #{file_name}" do + it "mentions the file name in the error so users know where to look for it" do + file_name = File.expand_path(file_name) if file_name.start_with?("~") + File.open(File.expand_path(file_name), "w") { |f| f << "--default_path" } + expect_parsing_to_fail_mentioning_source(file_name) + end + end + end + + context "defined in SPEC_OPTS" do + it "mentions ENV['SPEC_OPTS'] as the source in the error so users know where to look for it" do + with_env_vars 'SPEC_OPTS' => "--default_path" do + expect_parsing_to_fail_mentioning_source("ENV['SPEC_OPTS']") + end + end + end + + context "defined in a custom file" do + it "mentions the custom file as the source of the error so users know where to look for it" do + File.open("./custom.opts", "w") {|f| f << "--default_path"} + + expect_parsing_to_fail_mentioning_source("./custom.opts", %w[-O ./custom.opts]) + end + + context "passed at the command line" do + it "does not mention the source since it is obvious where it came from" do + expect { + parse_options("--default_path") + }.to raise_error(SystemExit).and output(a_string_including( + "invalid option: --default_path\n", + "Please use --help for a listing of valid options" + )).to_stderr + end + end + end + end + describe "sources: ~/.rspec, ./.rspec, ./.rspec-local, custom, CLI, and SPEC_OPTS" do it "merges global, local, SPEC_OPTS, and CLI" do File.open("./.rspec", "w") {|f| f << "--require some_file"} @@ -363,6 +430,16 @@ end end + it 'ignores file or dir names put in one of the option files or in SPEC_OPTS, since those are for persistent options' do + File.open("./.rspec", "w") { |f| f << "path/to/spec_1.rb" } + File.open("./.rspec-local", "w") { |f| f << "path/to/spec_2.rb" } + File.open(File.expand_path("~/.rspec"), "w") {|f| f << "path/to/spec_3.rb"} + with_env_vars 'SPEC_OPTS' => "path/to/spec_4.rb" do + options = parse_options() + expect(options[:files_or_directories_to_run]).to eq([]) + end + end + it "prefers SPEC_OPTS over CLI" do with_env_vars 'SPEC_OPTS' => "--format spec_opts" do expect(parse_options("--format", "cli")[:formatters]).to eq([['spec_opts']]) diff --git a/spec/rspec/core/configuration_spec.rb b/spec/rspec/core/configuration_spec.rb index 331d734ee3..c510961356 100644 --- a/spec/rspec/core/configuration_spec.rb +++ b/spec/rspec/core/configuration_spec.rb @@ -752,13 +752,6 @@ def specify_consistent_ordering_of_files_to_run end end - describe "path with line number" do - it "assigns the line number as a location filter" do - assign_files_or_directories_to_run "path/to/a_spec.rb:37" - expect(inclusion_filter).to eq({:locations => {File.expand_path("path/to/a_spec.rb") => [37]}}) - end - end - context "with full_description set" do it "overrides filters" do config.filter_run :focused => true @@ -803,6 +796,42 @@ def specify_consistent_ordering_of_files_to_run end end + context "with an example id" do + it "assigns the file and id as an ids filter" do + assign_files_or_directories_to_run "./path/to/a_spec.rb[1:2]" + expect(inclusion_filter).to eq(:ids => { "./path/to/a_spec.rb" => ["1:2"] }) + end + end + + context "with a single file with multiple example ids" do + it "assigns the file and ids as an ids filter" do + assign_files_or_directories_to_run "./path/to/a_spec.rb[1:2,1:3]" + expect(inclusion_filter).to eq(:ids => { "./path/to/a_spec.rb" => ["1:2", "1:3"] }) + end + + it "ignores whitespace between scoped ids" do + assign_files_or_directories_to_run "./path/to/a_spec.rb[1:2 , 1:3]" + expect(inclusion_filter).to eq(:ids => { "./path/to/a_spec.rb" => ["1:2", "1:3"] }) + end + end + + context "with multiple files with ids" do + it "assigns all of them to the ids filter" do + assign_files_or_directories_to_run "./path/to/a_spec.rb[1:2,1:3]", "./path/to/b_spec.rb[1:4]" + expect(inclusion_filter).to eq(:ids => { + "./path/to/a_spec.rb" => ["1:2", "1:3"], + "./path/to/b_spec.rb" => ["1:4"] + }) + end + end + + context "with the same file specified multiple times with different scoped ids" do + it "unions all the ids" do + assign_files_or_directories_to_run "./path/to/a_spec.rb[1:2]", "./path/to/a_spec.rb[1:3]" + expect(inclusion_filter).to eq(:ids => { "./path/to/a_spec.rb" => ["1:2", "1:3"] }) + end + end + it "assigns the example name as the filter on description" do config.full_description = "foo" expect(inclusion_filter).to eq({:full_description => /foo/}) @@ -850,6 +879,17 @@ def metadata_hash(*args) expect(group).not_to respond_to(:you_call_this_a_blt?) expect(group.new.you_call_this_a_blt?).to eq("egad man, where's the mayo?!?!?") end + + it "includes the given module into each existing example group" do + group = RSpec.describe('does like, stuff and junk', :magic_key => :include) { } + + RSpec.configure do |c| + c.include(InstanceLevelMethods) + end + + expect(group).not_to respond_to(:you_call_this_a_blt?) + expect(group.new.you_call_this_a_blt?).to eq("egad man, where's the mayo?!?!?") + end end context "with a filter" do @@ -863,6 +903,25 @@ def metadata_hash(*args) expect(group.new.you_call_this_a_blt?).to eq("egad man, where's the mayo?!?!?") end + it "includes the given module into each existing matching example group" do + matching_group = RSpec.describe('does like, stuff and junk', :magic_key => :include) { } + non_matching_group = RSpec.describe + nested_matching_group = non_matching_group.describe("", :magic_key => :include) + + RSpec.configure do |c| + c.include(InstanceLevelMethods, :magic_key => :include) + end + + expect(matching_group).not_to respond_to(:you_call_this_a_blt?) + expect(matching_group.new.you_call_this_a_blt?).to eq("egad man, where's the mayo?!?!?") + + expect(non_matching_group).not_to respond_to(:you_call_this_a_blt?) + expect(non_matching_group.new).not_to respond_to(:you_call_this_a_blt?) + + expect(nested_matching_group).not_to respond_to(:you_call_this_a_blt?) + expect(nested_matching_group.new.you_call_this_a_blt?).to eq("egad man, where's the mayo?!?!?") + end + it "includes the given module into the singleton class of matching examples" do RSpec.configure do |c| c.include(InstanceLevelMethods, :magic_key => :include) @@ -952,6 +1011,19 @@ def metadata_hash(*args) expect(group).to respond_to(:that_thing) end + it "extends the given module into each existing matching example group" do + matching_group = RSpec.describe(ThatThingISentYou, :magic_key => :extend) { } + non_matching_group = RSpec.describe + nested_matching_group = non_matching_group.describe("Other", :magic_key => :extend) + + RSpec.configure do |c| + c.extend(ThatThingISentYou, :magic_key => :extend) + end + + expect(matching_group).to respond_to(:that_thing) + expect(non_matching_group).not_to respond_to(:that_thing) + expect(nested_matching_group).to respond_to(:that_thing) + end end describe "#prepend", :if => RSpec::Support::RubyFeatures.module_prepends_supported? do @@ -979,6 +1051,16 @@ def metadata_hash(*args) group = RSpec.describe('yo') { } expect(group.new.foo).to eq("foobar") end + + it "prepends the given module into each existing example group" do + group = RSpec.describe('yo') { } + + RSpec.configure do |c| + c.prepend(SomeRandomMod) + end + + expect(group.new.foo).to eq("foobar") + end end context "with a filter" do @@ -990,6 +1072,20 @@ def metadata_hash(*args) group = RSpec.describe('yo', :magic_key => :include) { } expect(group.new.foo).to eq("foobar") end + + it "prepends the given module into each existing matching example group" do + matching_group = RSpec.describe('yo', :magic_key => :include) { } + non_matching_group = RSpec.describe + nested_matching_group = non_matching_group.describe('', :magic_key => :include) + + RSpec.configure do |c| + c.prepend(SomeRandomMod, :magic_key => :include) + end + + expect(matching_group.new.foo).to eq("foobar") + expect(non_matching_group.new).not_to respond_to(:foo) + expect(nested_matching_group.new.foo).to eq("foobar") + end end end @@ -1312,6 +1408,20 @@ def metadata_hash(*args) end end + describe "#backtrace_inclusion_patterns" do + before { config.backtrace_exclusion_patterns << /.*/ } + + it 'can be assigned to' do + config.backtrace_inclusion_patterns = [/foo/] + expect(config.backtrace_formatter.exclude?("food")).to be false + end + + it 'can be appended to' do + config.backtrace_inclusion_patterns << /foo/ + expect(config.backtrace_formatter.exclude?("food")).to be false + end + end + describe "#filter_gems_from_backtrace" do def exclude?(line) config.backtrace_formatter.exclude?(line) @@ -1328,6 +1438,22 @@ def exclude?(line) end end + describe "#profile_examples" do + it "defaults to false" do + expect(config.profile_examples).to be false + end + + it "can be set to an integer value" do + config.profile_examples = 17 + expect(config.profile_examples).to eq(17) + end + + it "returns 10 when set simply enabled" do + config.profile_examples = true + expect(config.profile_examples).to eq(10) + end + end + describe "#libs=" do it "adds directories to the LOAD_PATH" do expect($LOAD_PATH).to receive(:unshift).with("a/dir") @@ -1701,18 +1827,21 @@ def metadata_hash(*args) end end + def example_numbered(num) + instance_double(Example, :id => "./foo_spec.rb[1:#{num}]") + end + describe "#force" do context "for ordering options" do - let(:list) { [1, 2, 3, 4] } + let(:list) { 1.upto(4).map { |i| example_numbered(i) } } let(:ordering_strategy) { config.ordering_registry.fetch(:global) } - let(:rng) { RSpec::Core::RandomNumberGenerator.new config.seed } - let(:shuffled) { Ordering::Random.new(config).shuffle list, rng } + let(:shuffled) { Ordering::Random.new(config).order list } specify "CLI `--order defined` takes precedence over `config.order = rand`" do config.force :order => "defined" config.order = "rand" - expect(ordering_strategy.order(list)).to eq([1, 2, 3, 4]) + expect(ordering_strategy.order(list)).to eq(list) end specify "CLI `--order rand:37` takes precedence over `config.order = defined`" do @@ -1734,7 +1863,7 @@ def metadata_hash(*args) specify "CLI `--order defined` takes precedence over `config.register_ordering(:global)`" do config.force :order => "defined" config.register_ordering(:global, &:reverse) - expect(ordering_strategy.order(list)).to eq([1, 2, 3, 4]) + expect(ordering_strategy.order(list)).to eq(list) end end @@ -1758,7 +1887,7 @@ def metadata_hash(*args) describe "#seed_used?" do def use_seed_on(registry) - registry.fetch(:random).order([1, 2]) + registry.fetch(:random).order([example_numbered(1), example_numbered(2)]) end it 'returns false if neither ordering registry used the seed' do @@ -2031,6 +2160,20 @@ def emulate_not_configured_expectation_framework include_examples "warning of deprecated `:example_group` during filtering configuration", :before, :each end + describe '#threadsafe', :threadsafe => true do + it 'defaults to false' do + expect(config.threadsafe).to eq true + end + + it 'can be configured to true or false' do + config.threadsafe = true + expect(config.threadsafe).to eq true + + config.threadsafe = false + expect(config.threadsafe).to eq false + end + end + # assigns files_or_directories_to_run and triggers post-processing # via `files_to_run`. def assign_files_or_directories_to_run(*value) diff --git a/spec/rspec/core/drb_spec.rb b/spec/rspec/core/drb_spec.rb index 505294d565..1fd1585971 100644 --- a/spec/rspec/core/drb_spec.rb +++ b/spec/rspec/core/drb_spec.rb @@ -74,7 +74,6 @@ def self.run(argv, err, out) before(:all) do @drb_port = '8990' - @drb_example_file_counter = 0 DRb::start_service("druby://127.0.0.1:#{@drb_port}", SimpleDRbSpecServer) end @@ -82,6 +81,15 @@ def self.run(argv, err, out) DRb::stop_service end + it "falls back to `druby://:0` when `druby://localhost:0` fails" do + # see https://bugs.ruby-lang.org/issues/496 for background + expect(::DRb).to receive(:start_service).with("druby://localhost:0").and_raise(SocketError) + expect(::DRb).to receive(:start_service).with("druby://:0").and_call_original + + result = runner("--drb-port", @drb_port, passing_spec_filename).run(err, out) + expect(result).to be(0) + end + it "returns 0 if spec passes" do result = runner("--drb-port", @drb_port, passing_spec_filename).run(err, out) expect(result).to be(0) diff --git a/spec/rspec/core/example_group_spec.rb b/spec/rspec/core/example_group_spec.rb index f19175777f..2db12bb080 100644 --- a/spec/rspec/core/example_group_spec.rb +++ b/spec/rspec/core/example_group_spec.rb @@ -168,6 +168,16 @@ def metadata_hash(*args) }.to raise_error(/ExampleGroups::CallingAnUndefinedMethod/) end + it "assigns the const before including shared contexts via metadata so error messages from eval'ing the context include the name" do + RSpec.shared_context("foo", :foo) { bar } + + expect { + RSpec.describe("Including shared context via metadata", :foo) + }.to raise_error(NameError, + a_string_including('ExampleGroups::IncludingSharedContextViaMetadata', 'bar') + ) + end + it 'does not have problems with example groups named "Core"', :unless => RUBY_VERSION == '1.9.2' do RSpec.describe("Core") expect(defined?(::RSpec::ExampleGroups::Core)).to be_truthy @@ -1366,29 +1376,13 @@ def extract_execution_results(group) let(:group) { RSpec.describe } before do - allow(RSpec.world).to receive(:wants_to_quit) { true } - allow(RSpec.world).to receive(:clear_remaining_example_groups) + RSpec.world.wants_to_quit = true end it "returns without starting the group" do expect(reporter).not_to receive(:example_group_started) self.group.run(reporter) end - - context "at top level" do - it "purges remaining groups" do - expect(RSpec.world).to receive(:clear_remaining_example_groups) - self.group.run(reporter) - end - end - - context "in a nested group" do - it "does not purge remaining groups" do - nested_group = self.group.describe - expect(RSpec.world).not_to receive(:clear_remaining_example_groups) - nested_group.run(reporter) - end - end end context "with all examples passing" do @@ -1466,7 +1460,7 @@ def extract_execution_results(group) it "leaves RSpec's thread metadata unchanged" do expect { self.group.send(name, "named this") - }.to avoid_changing(RSpec, :thread_local_metadata) + }.to avoid_changing(RSpec::Support, :thread_local_data) end it "leaves RSpec's thread metadata unchanged, even when an error occurs during evaluation" do @@ -1474,7 +1468,7 @@ def extract_execution_results(group) self.group.send(name, "named this") do raise "boom" end - }.to raise_error("boom").and avoid_changing(RSpec, :thread_local_metadata) + }.to raise_error("boom").and avoid_changing(RSpec::Support, :thread_local_data) end it "passes parameters to the shared content" do @@ -1643,7 +1637,7 @@ def foo; end shared_examples_for("stuff") { } it_should_behave_like "stuff" end - }.to avoid_changing(RSpec, :thread_local_metadata) + }.to avoid_changing(RSpec::Support, :thread_local_data) end it "leaves RSpec's thread metadata unchanged, even when an error occurs during evaluation" do @@ -1654,7 +1648,7 @@ def foo; end raise "boom" end end - }.to raise_error("boom").and avoid_changing(RSpec, :thread_local_metadata) + }.to raise_error("boom").and avoid_changing(RSpec::Support, :thread_local_data) end end diff --git a/spec/rspec/core/example_spec.rb b/spec/rspec/core/example_spec.rb index 7fa62cba8a..bdb612ad06 100644 --- a/spec/rspec/core/example_spec.rb +++ b/spec/rspec/core/example_spec.rb @@ -22,12 +22,17 @@ def metadata_hash(*args) expect { ignoring_warnings { pp example_instance }}.to output(/RSpec::Core::Example/).to_stdout end - describe "#exception" do - it "supplies the first exception raised, if any" do - RSpec.configuration.output_stream = StringIO.new + describe "#rerun_argument" do + it "returns the location-based rerun argument" do + allow(RSpec.configuration).to receive_messages(:loaded_spec_files => [__FILE__]) + example = RSpec.describe.example + expect(example.rerun_argument).to eq("#{RSpec::Core::Metadata.relative_path(__FILE__)}:#{__LINE__ - 1}") + end + end + describe "#exception" do + it "supplies the exception raised, if there is one" do example = example_group.example { raise "first" } - example_group.after { raise "second" } example_group.run expect(example.exception.message).to eq("first") end @@ -37,6 +42,25 @@ def metadata_hash(*args) example_group.run expect(example.exception).to be_nil end + + it 'provides a `MultipleExceptionError` if there are multiple exceptions (e.g. from `it`, `around` and `after`)' do + the_example = nil + + after_ex = StandardError.new("after") + around_ex = StandardError.new("around") + example_ex = StandardError.new("example") + + RSpec.describe do + the_example = example { raise example_ex } + after { raise after_ex } + around { |ex| ex.run; raise around_ex } + end.run + + expect(the_example.exception).to have_attributes( + :class => RSpec::Core::MultipleExceptionError, + :all_exceptions => [example_ex, after_ex, around_ex] + ) + end end describe "when there is an explicit description" do @@ -187,12 +211,28 @@ def assert(val) end context "when `expect_with :stdlib` is configured" do - before(:each) { expect_with :stdlib } + around do |ex| + # Prevent RSpec::Matchers from being autoloaded. + orig_autoloads = RSpec::MODULES_TO_AUTOLOAD.dup + RSpec::MODULES_TO_AUTOLOAD.clear + ex.run + RSpec::MODULES_TO_AUTOLOAD.replace(orig_autoloads) + end - it "does not attempt to get the generated description from RSpec::Matchers" do - expect(RSpec::Matchers).not_to receive(:generated_description) - example_group.example { assert 5 == 5 } + before { expect_with :stdlib } + + it "does not attempt to get the generated description from RSpec::Matchers when not loaded" do + # Hide the constant while the example runs to simulate it being unloaded. + example_group.before { hide_const("RSpec::Matchers") } + + ex = example_group.example { assert 5 == 5 } example_group.run + + # We rescue errors that occur while generating the description and append it, + # so this ensures that no error mentioning `RSpec::Matchers` occurred while + # generating the description. + expect(ex.description).not_to include("RSpec::Matchers") + expect(ex).to pass end it "uses the file and line number" do @@ -227,14 +267,6 @@ def assert(val) end describe "#run" do - it "sets its reference to the example group instance to nil" do - group = RSpec.describe do - example('example') { expect(1).to eq(1) } - end - group.run - expect(group.examples.first.instance_variable_get("@example_group_instance")).to be_nil - end - it "generates a description before tearing down mocks in case a mock object is used in the description" do group = RSpec.describe do example { test = double('Test'); expect(test).to eq test } @@ -322,95 +354,83 @@ def assert(val) ]) end - context "clearing ivars" do - it "sets ivars to nil to prep them for GC" do - group = RSpec.describe do - before(:all) { @before_all = :before_all } - before(:each) { @before_each = :before_each } - after(:each) { @after_each = :after_each } - after(:all) { @after_all = :after_all } - end - group.example("does something") do - expect(@before_all).to eq(:before_all) - expect(@before_each).to eq(:before_each) - end - expect(group.run(double.as_null_object)).to be_truthy - group.new do |example| - %w[@before_all @before_each @after_each @after_all].each do |ivar| - expect(example.instance_variable_get(ivar)).to be_nil - end - end - end - it "does not impact the before_all_ivars which are copied to each example" do - group = RSpec.describe do - before(:all) { @before_all = "abc" } - example("first") { expect(@before_all).not_to be_nil } - example("second") { expect(@before_all).not_to be_nil } - end - expect(group.run).to be_truthy + context 'memory leaks, see GH-321, GH-1921' do + def self.reliable_gc + 0 != GC.method(:start).arity # older Rubies don't give us options to ensure a full GC end - end - context 'when the example raises an error' do - def run_and_capture_reported_message(group) - reported_msg = nil - # We can't use should_receive(:message).with(/.../) here, - # because if that fails, it would fail within our example-under-test, - # and since there's already two errors, it would just be reported again. - allow(RSpec.configuration.reporter).to receive(:message) { |msg| reported_msg = msg } - group.run - reported_msg - end + def expect_gc(opts) + get_all = opts.fetch :get_all - it "prints any around hook errors rather than silencing them" do - group = RSpec.describe do - around(:each) { |e| e.run; raise "around" } - example("e") { raise "example" } + begin + GC.disable + opts.fetch(:event).call + expect(get_all.call).to eq(opts.fetch :pre_gc) + ensure + GC.enable end - message = run_and_capture_reported_message(group) - expect(message).to match(/An error occurred in an `around.* hook/i) - end - - it "prints any after hook errors rather than silencing them" do - group = RSpec.describe do - after(:each) { raise "after" } - example("e") { raise "example" } + # See discussion on https://github.com/rspec/rspec-core/pull/1950 + # for why it's necessary to do this multiple times + 20.times do + GC.start :full_mark => true, :immediate_sweep => true + return if get_all.call == opts.fetch(:post_gc) end - message = run_and_capture_reported_message(group) - expect(message).to match(/An error occurred in an after.* hook/i) + expect(get_all.call).to eq opts.fetch(:post_gc) end - it "does not print mock expectation errors" do + it 'releases references to the examples / their ivars', :if => reliable_gc do + config = RSpec::Core::Configuration.new + real_reporter = RSpec::Core::Reporter.new(config) # in case it is the cause of a leak + garbage = Struct.new :defined_in + group = RSpec.describe do - example do - foo = double - expect(foo).to receive(:bar) - raise "boom" + before(:all) { @before_all = garbage.new :before_all } + before(:each) { @before_each = garbage.new :before_each } + after(:each) { @after_each = garbage.new :after_each } + after(:all) { @after_all = garbage.new :after_all } + example "passing" do + @passing_example = garbage.new :passing_example + expect(@passing_example).to be + end + example "failing" do + @failing_example = garbage.new :failing_example + expect(@failing_example).to_not be end end - message = run_and_capture_reported_message(group) - expect(message).to be_nil + expect_gc :event => lambda { group.run real_reporter }, + :get_all => lambda { ObjectSpace.each_object(garbage).map { |g| g.defined_in.to_s }.sort }, + :pre_gc => %w[after_all after_each after_each before_all before_each before_each failing_example passing_example], + :post_gc => [] end - it "leaves a raised exception unmodified (GH-1103)" do - # set the backtrace, otherwise MRI will build a whole new object, - # and thus mess with our expectations. Rubinius and JRuby are not - # affected. - exception = StandardError.new - exception.set_backtrace([]) - - group = RSpec.describe do - example { raise exception.freeze } + it 'can still be referenced by user code afterwards' do + calls_a = nil + describe_successfully 'saves a lambda that references its memoized helper' do + let(:a) { 123 } + example { calls_a = lambda { a } } end - group.run + expect(calls_a.call).to eq 123 + end + end + + it "leaves raised exceptions unmodified (GH-1103)" do + # set the backtrace, otherwise MRI will build a whole new object, + # and thus mess with our expectations. Rubinius and JRuby are not + # affected. + exception = StandardError.new + exception.set_backtrace([]) - actual = group.examples.first.execution_result.exception - expect(actual.__id__).to eq(exception.__id__) + group = RSpec.describe do + example { raise exception.freeze } end + group.run + + actual = group.examples.first.execution_result.exception + expect(actual.__id__).to eq(exception.__id__) end context "with --dry-run" do @@ -462,23 +482,21 @@ def expect_pending_result(example) context "in the example" do it "sets the example to pending" do - group = RSpec.describe do + group = describe_successfully do example { pending; fail } end - group.run expect_pending_result(group.examples.first) end it "allows post-example processing in around hooks (see https://github.com/rspec/rspec-core/issues/322)" do blah = nil - group = RSpec.describe do + describe_successfully do around do |example| example.run blah = :success end - example { pending } + example { pending; fail } end - group.run expect(blah).to be(:success) end @@ -499,22 +517,20 @@ def expect_pending_result(example) context "in before(:each)" do it "sets each example to pending" do - group = RSpec.describe do + group = describe_successfully do before(:each) { pending } example { fail } example { fail } end - group.run expect_pending_result(group.examples.first) expect_pending_result(group.examples.last) end it 'sets example to pending when failure occurs in before(:each)' do - group = RSpec.describe do + group = describe_successfully do before(:each) { pending; fail } example {} end - group.run expect_pending_result(group.examples.first) end end @@ -550,32 +566,29 @@ def expect_pending_result(example) context "in around(:each)" do it "sets the example to pending" do - group = RSpec.describe do + group = describe_successfully do around(:each) { pending } example { fail } end - group.run expect_pending_result(group.examples.first) end it 'sets example to pending when failure occurs in around(:each)' do - group = RSpec.describe do + group = describe_successfully do around(:each) { pending; fail } example {} end - group.run expect_pending_result(group.examples.first) end end context "in after(:each)" do it "sets each example to pending" do - group = RSpec.describe do + group = describe_successfully do after(:each) { pending; fail } example { } example { } end - group.run expect_pending_result(group.examples.first) expect_pending_result(group.examples.last) end @@ -586,32 +599,29 @@ def expect_pending_result(example) describe "#skip" do context "in the example" do it "sets the example to skipped" do - group = RSpec.describe do + group = describe_successfully do example { skip } end - group.run expect(group.examples.first).to be_skipped end it "allows post-example processing in around hooks (see https://github.com/rspec/rspec-core/issues/322)" do blah = nil - group = RSpec.describe do + describe_successfully do around do |example| example.run blah = :success end example { skip } end - group.run expect(blah).to be(:success) end context "with a message" do it "sets the example to skipped with the provided message" do - group = RSpec.describe do + group = describe_successfully do example { skip "lorem ipsum" } end - group.run expect(group.examples.first).to be_skipped_with("lorem ipsum") end end @@ -619,12 +629,11 @@ def expect_pending_result(example) context "in before(:each)" do it "sets each example to skipped" do - group = RSpec.describe do + group = describe_successfully do before(:each) { skip } example {} example {} end - group.run expect(group.examples.first).to be_skipped expect(group.examples.last).to be_skipped end @@ -632,12 +641,11 @@ def expect_pending_result(example) context "in before(:all)" do it "sets each example to skipped" do - group = RSpec.describe do + group = describe_successfully do before(:all) { skip("not done"); fail } example {} example {} end - group.run expect(group.examples.first).to be_skipped_with("not done") expect(group.examples.last).to be_skipped_with("not done") end @@ -645,11 +653,10 @@ def expect_pending_result(example) context "in around(:each)" do it "sets the example to skipped" do - group = RSpec.describe do + group = describe_successfully do around(:each) { skip } example {} end - group.run expect(group.examples.first).to be_skipped end end @@ -672,12 +679,12 @@ def expect_pending_result(example) RSpec.configuration.order = :random - RSpec.describe do + describe_successfully do # The bug was only triggered when the examples # were in nested contexts; see https://github.com/rspec/rspec-core/pull/837 context { example { values << rand } } context { example { values << rand } } - end.run + end expect(values.uniq.count).to eq(2) end @@ -716,20 +723,50 @@ def expect_pending_result(example) expect(ex).to fail_with(RSpec::Mocks::MockExpectationError) end + it 'skips mock verification if the example has already failed' do + ex = nil + boom = StandardError.new("boom") + + RSpec.describe do + ex = example do + dbl = double + expect(dbl).to receive(:Foo) + raise boom + end + end.run + + expect(ex.exception).to be boom + end + it 'allows `after(:example)` hooks to satisfy mock expectations, since examples are not complete until their `after` hooks run' do ex = nil RSpec.describe do - let(:dbl) { double } + let(:the_dbl) { double } ex = example do - expect(dbl).to receive(:foo) + expect(the_dbl).to receive(:foo) end - after { dbl.foo } + after { the_dbl.foo } end.run expect(ex).to pass end end + + describe "exposing the examples reporter" do + it "returns a null reporter when the example hasnt run yet" do + example = RSpec.describe.example + expect(example.reporter).to be RSpec::Core::NullReporter + end + + it "returns the reporter used to run the example when executed" do + reporter = double(:reporter).as_null_object + group = RSpec.describe + example = group.example + example.run group.new, reporter + expect(example.reporter).to be reporter + end + end end diff --git a/spec/rspec/core/example_status_persister_spec.rb b/spec/rspec/core/example_status_persister_spec.rb new file mode 100644 index 0000000000..9320780d08 --- /dev/null +++ b/spec/rspec/core/example_status_persister_spec.rb @@ -0,0 +1,334 @@ +require 'rspec/core/example_status_persister' +require 'tempfile' + +module RSpec::Core + RSpec.describe "Example status persisting" do + it 'can load a previously persisted set of example statuses from disk' do + examples = [ + { :example_id => "spec_1.rb[1:1]", :status => "passed" }, + { :example_id => "spec_1.rb[1:2]", :status => "failed" } + ] + + temp_file = Tempfile.new("example_statuses.txt") + temp_file.write(ExampleStatusDumper.dump(examples)) + temp_file.close + + loaded = ExampleStatusPersister.load_from(temp_file.path) + expect(loaded).to eq(examples) + end + + it 'returns `[]` from `load_from` when the named file does not exist' do + expect(ExampleStatusPersister.load_from("./some/missing/path.txt")).to eq([]) + end + + describe "persisting example statuses" do + include FormatterSupport + + def new_example(id, metadata = {}) + super(metadata).tap do |ex| + allow(ex).to receive_messages(:id => id) + end + end + + let(:file) { Tempfile.new("example_statuses.txt") } + let(:existing_spec_file) { Metadata.relative_path(__FILE__) } + + it 'writes the given example statuses to disk' do + ex_1 = new_example("spec_1.rb[1:1]", :status => :passed) + ex_2 = new_example("spec_1.rb[1:2]", :status => :failed) + + ExampleStatusPersister.persist([ex_1, ex_2], file.path) + loaded = ExampleStatusPersister.load_from(file.path) + + expect(loaded).to contain_exactly( + a_hash_including(:example_id => ex_1.id, :status => "passed"), + a_hash_including(:example_id => ex_2.id, :status => "failed") + ) + end + + it 'creates any necessary intermediary directories' do + path = File.join("#{file.path}-some", "subdirectory", "examples.txt") + ex_1 = new_example("spec_1.rb[1:1]", :status => :passed) + + ExampleStatusPersister.persist([ex_1], path) + loaded = ExampleStatusPersister.load_from(path) + + expect(loaded).to contain_exactly( + a_hash_including(:example_id => ex_1.id, :status => "passed") + ) + end + + it 'merges the example statuses with the existing records in the named file' do + ex_1 = new_example("#{existing_spec_file}[1:1]", :status => :passed) + ex_2 = new_example("spec_1.rb[1:1]", :status => :failed) + + ExampleStatusPersister.persist([ex_1], file.path) + ExampleStatusPersister.persist([ex_2], file.path) + loaded = ExampleStatusPersister.load_from(file.path) + + expect(loaded).to contain_exactly( + a_hash_including(:example_id => ex_1.id, :status => "passed"), + a_hash_including(:example_id => ex_2.id, :status => "failed") + ) + end + + it 'includes the spec run times so users can use it for their own purposes' do + ex_1 = new_example("spec_1.rb[1:1]", :status => :passed) + allow(ex_1.execution_result).to receive(:run_time) { 3.0 } + + ExampleStatusPersister.persist([ex_1], file.path) + loaded = ExampleStatusPersister.load_from(file.path) + + expect(loaded).to match [ a_hash_including(:run_time => "3 seconds") ] + end + + it "persists a loaded but unexecuted example with an #{Configuration::UNKNOWN_STATUS} status" do + ex_1 = RSpec.describe.example + + ExampleStatusPersister.persist([ex_1], file.path) + loaded = ExampleStatusPersister.load_from(file.path) + + expect(loaded).to match [ a_hash_including( + :example_id => ex_1.id, :status => Configuration::UNKNOWN_STATUS + ) ] + end + + it "persists a skipped example properly" do + group = RSpec.describe + ex_1 = group.example("foo", :skip) + group.run + + ExampleStatusPersister.persist([ex_1], file.path) + loaded = ExampleStatusPersister.load_from(file.path) + + expect(loaded).to match [ a_hash_including( :example_id => ex_1.id, :status => "pending") ] + end + end + end + + RSpec.describe "Example status merging" do + let(:existing_spec_file) { Metadata.relative_path(__FILE__) } + + context "when no examples from this or previous runs are given" do + it "returns an empty array" do + merged = merge(:this_run => [], :from_previous_runs => []) + expect(merged).to eq([]) + end + end + + context "when there are no examples from previous runs" do + it "returns the examples from this run" do + this_run = [ + example(existing_spec_file, "1:1", "passed"), + example(existing_spec_file, "1:2", "failed") + ] + + merged = merge(:this_run => this_run, :from_previous_runs => []) + expect(merged).to match_array(this_run) + end + end + + context "when there are no examples from this run" do + it "returns the examples from the previous runs" do + from_previous_runs = [ + example(existing_spec_file, "1:1", "passed"), + example(existing_spec_file, "1:2", "failed") + ] + + merged = merge(:this_run => [], :from_previous_runs => from_previous_runs) + expect(merged).to match_array(from_previous_runs) + end + end + + context "for examples that are only in the set for this run" do + it "takes them indiscriminately, even if they did not execute" do + this_run = [ example(existing_spec_file, "1:1", Configuration::UNKNOWN_STATUS) ] + + merged = merge(:this_run => this_run, :from_previous_runs => []) + expect(merged).to match_array(this_run) + end + end + + context "for examples that are only in the set for previous runs" do + context "if there are other examples from this run for the same file " do + it "deletes them since the examples must no longer exist" do + this_run = [ example(existing_spec_file, "1:1", "passed") ] + from_previous_runs = [ example(existing_spec_file, "1:2", "failed") ] + + merged = merge(:this_run => this_run, :from_previous_runs => from_previous_runs) + expect(merged).to match_array(this_run) + end + end + + context "if there are no other examples from this run for the same file" do + it "deletes them if the file no longer exist" do + from_previous_runs = [ example("./some/deleted_path/foo_spec.rb", "1:2", "failed") ] + + merged = merge(:this_run => [], :from_previous_runs => from_previous_runs) + expect(merged).to eq([]) + end + + it "keeps them if the file exists because the examples may still exist" do + from_previous_runs = [ example(existing_spec_file, "1:2", "failed") ] + + merged = merge(:this_run => [], :from_previous_runs => from_previous_runs) + expect(merged).to eq(from_previous_runs) + end + end + end + + context "for examples that are in both sets" do + it "takes the status from this run as long as the example executed" do + this_run = [ example("foo_spec.rb", "1:1", "passed") ] + from_previous_runs = [ example("foo_spec.rb", "1:1", "failed") ] + + merged = merge(:this_run => this_run, :from_previous_runs => from_previous_runs) + expect(merged).to match_array(this_run) + end + + it "takes the status from previous runs if the example was loaded but did not execute" do + this_run = [ example("foo_spec.rb", "1:1", Configuration::UNKNOWN_STATUS) ] + from_previous_runs = [ example("foo_spec.rb", "1:1", "failed") ] + + merged = merge(:this_run => this_run, :from_previous_runs => from_previous_runs) + expect(merged).to match_array(from_previous_runs) + end + end + + it 'sorts the returned examples to make the saved file more easily scannable' do + this_run = [ + ex_c_1_1 = example("c_spec.rb", "1:1", "passed"), + ex_a_1_2 = example("a_spec.rb", "1:2", "failed"), + ex_a_1_10 = example("a_spec.rb", "1:10", "failed"), + ex_a_1_9 = example("a_spec.rb", "1:9", "failed"), + ] + + merged = merge(:this_run => this_run, :from_previous_runs => []) + expect(merged).to eq([ ex_a_1_2, ex_a_1_9, ex_a_1_10, ex_c_1_1 ]) + end + + it "preserves any extra attributes include in the example hashes" do + this_run = [ + example(existing_spec_file, "1:1", "passed", :foo => 23), + example(existing_spec_file, "1:2", "failed", :bar => 12) + ] + + from_previous_runs = [ + example(existing_spec_file, "1:1", "passed", :foo => -23), + example(existing_spec_file, "1:2", "failed", :bar => -12) + ] + + merged = merge(:this_run => this_run, :from_previous_runs => from_previous_runs) + expect(merged).to contain_exactly( + a_hash_including(:foo => 23), + a_hash_including(:bar => 12) + ) + end + + def example(file, scoped_id, status, extras = {}) + { :example_id => "#{file}[#{scoped_id}]", :status => status }.merge(extras) + end + + def merge(options) + ExampleStatusMerger.merge( + options.fetch(:this_run), + options.fetch(:from_previous_runs) + ) + end + end + + RSpec.describe "Example status serialization" do + it 'serializes the provided example statuses in a human readable format' do + examples = [ + { :example_id => "./spec/unit/foo_spec.rb[1:1]", :status => 'passed' }, + { :example_id => "./spec/unit/foo_spec.rb[1:2]", :status => 'pending' }, + { :example_id => "./spec/integration/foo_spec.rb[1:2]", :status => 'failed' } + ] + + produce_expected_output = eq(unindent(<<-EOS)) + example_id | status | + ----------------------------------- | ------- | + ./spec/unit/foo_spec.rb[1:1] | passed | + ./spec/unit/foo_spec.rb[1:2] | pending | + ./spec/integration/foo_spec.rb[1:2] | failed | + EOS + + if RUBY_VERSION == '1.8.7' # unordered hashes :(. + produce_expected_output |= eq(unindent(<<-EOS)) + status | example_id | + ------- | ----------------------------------- | + passed | ./spec/unit/foo_spec.rb[1:1] | + pending | ./spec/unit/foo_spec.rb[1:2] | + failed | ./spec/integration/foo_spec.rb[1:2] | + EOS + end + + expect(dump(examples)).to produce_expected_output + end + + it 'takes the column headers into account when sizing the columns' do + examples = [ + { :long_key => '12', :a => '20' }, + { :long_key => '120', :a => '2' } + ] + + produce_expected_output = eq(unindent(<<-EOS)) + long_key | a | + -------- | -- | + 12 | 20 | + 120 | 2 | + EOS + + if RUBY_VERSION == '1.8.7' # unordered hashes :(. + produce_expected_output |= eq(unindent(<<-EOS)) + a | long_key | + -- | -------- | + 20 | 12 | + 2 | 120 | + EOS + end + + expect(dump(examples)).to produce_expected_output + end + + it 'can round trip through the dumper and parser' do + examples = [ + { :example_id => "./spec/unit/foo_spec.rb[1:1]", :status => 'passed' }, + { :example_id => "./spec/unit/foo_spec.rb[1:2]", :status => 'pending' }, + { :example_id => "./spec/integration/foo_spec.rb[1:2]", :status => 'failed' } + ] + + round_tripped = parse(dump(examples)) + expect(round_tripped).to eq(examples) + end + + it 'can round trip blank values through the dumper and parser' do + examples = [ + { :example_id => "./spec/unit/foo_spec.rb[1:1]", :run_time => '1 second' }, + { :example_id => "./spec/unit/foo_spec.rb[1:2]", :run_time => '' } + ] + + round_tripped = parse(dump(examples)) + expect(round_tripped).to eq(examples) + end + + it 'produces nothing when given nothing' do + expect(dump([])).to eq(nil) + end + + # Intended for use with indented heredocs. + # taken from Ruby Tapas: + # https://rubytapas.dpdcart.com/subscriber/post?id=616#files + def unindent(s) + s.gsub(/^#{s.scan(/^[ \t]+(?=\S)/).min}/, "") + end + + def dump(examples) + ExampleStatusDumper.dump(examples) + end + + def parse(string) + ExampleStatusParser.parse(string) + end + end +end diff --git a/spec/rspec/core/filter_manager_spec.rb b/spec/rspec/core/filter_manager_spec.rb index 676a6ba3f4..63173cbb42 100644 --- a/spec/rspec/core/filter_manager_spec.rb +++ b/spec/rspec/core/filter_manager_spec.rb @@ -8,6 +8,17 @@ def opposite(name) let(:inclusions) { filter_manager.inclusions } let(:exclusions) { filter_manager.exclusions } + def prune(examples) + # We want to enforce that our FilterManager, like a good citizen, + # leaves the input array unmodified. There are a lot of code paths + # through the filter manager, so rather than write one + # `it 'does not mutate the input'` example that would not cover + # all code paths, we're freezing the input here in order to + # enforce that for ALL examples in this file that call `prune`, + # the input array is not mutated. + filter_manager.prune(examples.freeze) + end + %w[include inclusions exclude exclusions].each_slice(2) do |name, type| describe "##{name}" do subject(:rules) { send(type).rules } @@ -105,24 +116,105 @@ def example_with(*args) RSpec.describe("group", *args).example("example") end - it "prefers location to exclusion filter" do - group = RSpec.describe("group") - included = group.example("include", :slow => true) {} - excluded = group.example("exclude") {} - filter_manager.add_location(__FILE__, [__LINE__ - 2]) - filter_manager.exclude_with_low_priority :slow => true - expect(filter_manager.prune([included, excluded])).to eq([included]) + shared_examples_for "example identification filter preference" do |type| + it "prefers #{type} filter to exclusion filter" do + group = RSpec.describe("group") + included = group.example("include", :slow => true) {}; line = __LINE__ + excluded = group.example("exclude") {} + + add_filter(:line_number => line, :scoped_id => "1:1") + filter_manager.exclude_with_low_priority :slow => true + + expect(prune([included, excluded])).to eq([included]) + end + + it "prefers #{type} on entire group to exclusion filter on a nested example" do + # We way want to change this behaviour in future, see: + # https://github.com/rspec/rspec-core/issues/779 + group = RSpec.describe("group"); line = __LINE__ + included = group.example("include", :slow => true) + excluded = RSpec.describe.example + + add_filter(:line_number => line, :scoped_id => "1") + filter_manager.exclude_with_low_priority :slow => true + + expect(prune([included, excluded])).to eq([included]) + end + + it "still applies inclusion filters to examples from files with no #{type} filters" do + group = RSpec.describe("group") + included_via_loc_or_id = group.example("inc via #{type}"); line = __LINE__ + excluded_via_loc_or_id = group.example("exc via #{type}", :foo) + + included_via_tag, excluded_via_tag = instance_eval <<-EOS, "some/other_spec.rb", 1 + group = RSpec.describe("group") + [group.example("inc via tag", :foo), group.example("exc via tag")] + EOS + + add_filter(:line_number => line, :scoped_id => "1:1") + filter_manager.include_with_low_priority :foo => true + + expect(prune([ + included_via_loc_or_id, excluded_via_loc_or_id, + included_via_tag, excluded_via_tag + ]).map(&:description)).to eq([included_via_loc_or_id, included_via_tag].map(&:description)) + end + + it "skips examples in external files when included from a #{type} filtered file" do + group = RSpec.describe("group") + + included_via_loc_or_id = group.example("inc via #{type}"); line = __LINE__ + + # instantiate shared example in external file + instance_eval <<-EOS, "a_shared_example.rb", 1 + RSpec.shared_examples_for("a shared example") do + example("inside of a shared example") + end + EOS + + included_via_behaves_like = group.it_behaves_like("a shared example") + test_inside_a_shared_example = included_via_behaves_like.examples.first + + add_filter(:line_number => line, :scoped_id => "1:1") + + expect(prune([ + included_via_loc_or_id, test_inside_a_shared_example + ]).map(&:description)).to eq([included_via_loc_or_id].map(&:description)) + end end - it "prefers location to exclusion filter on entire group" do - # We way want to change this behaviour in future, see: - # https://github.com/rspec/rspec-core/issues/779 - group = RSpec.describe("group") - included = group.example("include", :slow => true) {} - excluded = example_with - filter_manager.add_location(__FILE__, [__LINE__ - 3]) - filter_manager.exclude_with_low_priority :slow => true - expect(filter_manager.prune([included, excluded])).to eq([included]) + describe "location filtering" do + include_examples "example identification filter preference", :location do + def add_filter(options) + filter_manager.add_location(__FILE__, [options.fetch(:line_number)]) + end + end + end + + describe "id filtering" do + include_examples "example identification filter preference", :id do + def add_filter(options) + filter_manager.add_ids(__FILE__, [options.fetch(:scoped_id)]) + end + end + end + + context "with a location and an id filter" do + it 'takes the set union of matched examples' do + group = RSpec.describe("group") + + matches_id = group.example + matches_line_number = group.example; line_1 = __LINE__ + matches_both = group.example; line_2 = __LINE__ + matches_neither = group.example + + filter_manager.add_ids(__FILE__, ["1:1", "1:3"]) + filter_manager.add_location(__FILE__, [line_1, line_2]) + + expect(prune([ + matches_id, matches_line_number, matches_both, matches_neither + ])).to eq([matches_id, matches_line_number, matches_both]) + end end context "with examples from multiple spec source files" do @@ -139,7 +231,7 @@ def example_with(*args) expect { filter_manager.add_location(__FILE__, [line]) }.to change { - filter_manager.prune([this_file_example, other_file_example]).map(&:description) + prune([this_file_example, other_file_example]).map(&:description) }.from([]).to([this_file_example.description]) end end @@ -150,21 +242,21 @@ def example_with(*args) excluded = group.example("exclude") {} filter_manager.include(:full_description => /include/) filter_manager.exclude_with_low_priority :slow => true - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "includes objects with tags matching inclusions" do included = example_with({:foo => :bar}) excluded = example_with filter_manager.include :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "excludes objects with tags matching exclusions" do included = example_with excluded = example_with({:foo => :bar}) filter_manager.exclude :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "prefers exclusion when matches previously set inclusion" do @@ -172,7 +264,7 @@ def example_with(*args) excluded = example_with({:foo => :bar}) filter_manager.include :foo => :bar filter_manager.exclude :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "prefers inclusion when matches previously set exclusion" do @@ -180,7 +272,7 @@ def example_with(*args) excluded = example_with filter_manager.exclude :foo => :bar filter_manager.include :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "prefers previously set inclusion when exclusion matches but has lower priority" do @@ -188,7 +280,7 @@ def example_with(*args) excluded = example_with filter_manager.include :foo => :bar filter_manager.exclude_with_low_priority :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end it "prefers previously set exclusion when inclusion matches but has lower priority" do @@ -196,7 +288,7 @@ def example_with(*args) excluded = example_with({:foo => :bar}) filter_manager.exclude :foo => :bar filter_manager.include_with_low_priority :foo => :bar - expect(filter_manager.prune([included, excluded])).to eq([included]) + expect(prune([included, excluded])).to eq([included]) end context "with multiple inclusion filters" do @@ -208,7 +300,74 @@ def example_with(*args) ] filter_manager.include :foo => true, :bar => true - expect(filter_manager.prune(examples)).to contain_exactly(included_1, included_2) + expect(prune(examples)).to contain_exactly(included_1, included_2) + end + end + + context "with :id filters" do + it 'selects only the matched example when a single example id is given' do + ex_1 = ex_2 = nil + RSpec.describe do + ex_1 = example + ex_2 = example + end + + filter_manager.add_ids(Metadata.relative_path(__FILE__), %w[ 1:2 ]) + expect(prune([ex_1, ex_2])).to eq([ex_2]) + end + + it 'can work with absolute file paths' do + ex_1 = ex_2 = nil + RSpec.describe do + ex_1 = example + ex_2 = example + end + + filter_manager.add_ids(File.expand_path(__FILE__), %w[ 1:2 ]) + expect(prune([ex_1, ex_2])).to eq([ex_2]) + end + + it "can work with relative paths that lack the leading `.`" do + path = Metadata.relative_path(__FILE__).sub(/^\.\//, '') + + ex_1 = ex_2 = nil + RSpec.describe do + ex_1 = example + ex_2 = example + end + + filter_manager.add_ids(path, %w[ 1:2 ]) + expect(prune([ex_1, ex_2])).to eq([ex_2]) + end + + it 'can select groups' do + ex_1 = ex_2 = ex_3 = nil + RSpec.describe { ex_1 = example } + RSpec.describe do + ex_2 = example + ex_3 = example + end + + filter_manager.add_ids(Metadata.relative_path(__FILE__), %w[ 2 ]) + expect(prune([ex_1, ex_2, ex_3])).to eq([ex_2, ex_3]) + end + + it 'uses the rerun file path when applying the id filter' do + ex_1, ex_2 = instance_eval <<-EOS, "./some/spec.rb", 1 + ex_1 = ex_2 = nil + + RSpec.shared_examples "shared" do + ex_1 = example("ex 1") + ex_2 = example("ex 2") + end + + [ex_1, ex_2] + EOS + + RSpec.describe { include_examples "shared" } + + filter_manager.add_ids(__FILE__, %w[ 1:1 ]) + expect(prune([ex_1, ex_2]).map(&:description)).to eq([ex_1].map(&:description)) end end end @@ -278,7 +437,7 @@ def example_with_metadata(metadata) end def exclude?(example) - filter_manager.prune([example]).empty? + prune([example]).empty? end describe "the default :if filter" do diff --git a/spec/rspec/core/formatters/base_text_formatter_spec.rb b/spec/rspec/core/formatters/base_text_formatter_spec.rb index bd5d476137..d05a766504 100644 --- a/spec/rspec/core/formatters/base_text_formatter_spec.rb +++ b/spec/rspec/core/formatters/base_text_formatter_spec.rb @@ -5,58 +5,107 @@ include FormatterSupport context "when closing the formatter", :isolated_directory => true do + let(:output_to_close) { File.new("./output_to_close", "w") } + let(:formatter) { described_class.new(output_to_close) } + it 'does not close an already closed output stream' do - output_to_close = File.new("./output_to_close", "w") - formatter = described_class.new(output_to_close) output_to_close.close expect { formatter.close(RSpec::Core::Notifications::NullNotification) }.not_to raise_error end + + it "flushes output before closing the stream so buffered bytes are not lost if we exit right away" do + expect(output_to_close).to receive(:flush).ordered.and_call_original + # Windows appears to not let the `:isolated_directory` shared group cleanup if + # the file isn't closed, so we need to use `and_call_original` here. + expect(output_to_close).to receive(:close).ordered.and_call_original + + formatter.close(RSpec::Core::Notifications::NullNotification) + end end describe "#dump_summary" do it "with 0s outputs pluralized (excluding pending)" do send_notification :dump_summary, summary_notification(0, [], [], [], 0) - expect(output.string).to match("0 examples, 0 failures") + expect(formatter_output.string).to match("0 examples, 0 failures") end it "with 1s outputs singular (including pending)" do send_notification :dump_summary, summary_notification(0, examples(1), examples(1), examples(1), 0) - expect(output.string).to match("1 example, 1 failure, 1 pending") + expect(formatter_output.string).to match("1 example, 1 failure, 1 pending") end it "with 2s outputs pluralized (including pending)" do send_notification :dump_summary, summary_notification(2, examples(2), examples(2), examples(2), 0) - expect(output.string).to match("2 examples, 2 failures, 2 pending") + expect(formatter_output.string).to match("2 examples, 2 failures, 2 pending") end - it "includes command to re-run each failed example" do - example_group = RSpec.describe("example group") do - it("fails") { fail } + describe "rerun command for failed examples" do + it "uses the location to identify the example" do + line = __LINE__ + 2 + example_group = RSpec.describe("example group") do + it("fails") { fail } + end + + expect(output_from_running example_group).to include("rspec #{RSpec::Core::Metadata::relative_path("#{__FILE__}:#{line}")} # example group fails") end - line = __LINE__ - 2 - expect(output_from_running example_group).to include("rspec #{RSpec::Core::Metadata::relative_path("#{__FILE__}:#{line}")} # example group fails") - end + context "for an example defined in an file required by the user rather than loaded by rspec" do + it "looks through ancestor metadata to find a workable re-run command" do + line = __LINE__ + 1 + example_group = RSpec.describe("example group") do + # Using eval in order to make it think this got defined in an external file. + instance_eval "it('fails') { fail }", "some/external/file.rb", 1 + end - context "for an example defined in an file required by the user rather than loaded by rspec" do - it "looks through ancestor metadata to find a workable re-run command" do - line = __LINE__ + 1 - example_group = RSpec.describe("example group") do - # Using eval in order to make it think this got defined in an external file. - instance_eval "it('fails') { fail }", "some/external/file.rb", 1 + expect(output_from_running example_group).to include("rspec #{RSpec::Core::Metadata::relative_path("#{__FILE__}:#{line}")} # example group fails") end + end - expect(output_from_running example_group).to include("rspec #{RSpec::Core::Metadata::relative_path("#{__FILE__}:#{line}")} # example group fails") + context "for an example that is not uniquely identified by the location" do + let(:example_group_in_this_file) { example_group_defined_in(__FILE__) } + + def example_group_defined_in(file) + instance_eval <<-EOS, file, 1 + $group = RSpec.describe("example group") do + 1.upto(2) do |i| + it("compares \#{i} against 2") { expect(i).to eq(2) } + end + end + EOS + $group + end + + let(:id) { "#{RSpec::Core::Metadata::relative_path("#{__FILE__}")}[1:1]" } + + it "uses the id instead" do + with_env_vars 'SHELL' => '/usr/local/bin/bash' do + expect(output_from_running example_group_in_this_file).to include("rspec #{id} # example group compares 1 against 2") + end + end + + context "on a shell that may not handle unquoted ids" do + around { |ex| with_env_vars('SHELL' => '/usr/local/bin/cash', &ex) } + + it 'quotes the id to be safe so the rerun command can be copied and pasted' do + expect(output_from_running example_group_in_this_file).to include("rspec '#{id}'") + end + + it 'correctly escapes file names that have quotes in them' do + group_in_other_file = example_group_defined_in("./path/with'quote_spec.rb") + expect(output_from_running group_in_other_file).to include("rspec './path/with\\'quote_spec.rb[1:1]'") + end + end end - end - def output_from_running(example_group) - allow(RSpec.configuration).to receive(:loaded_spec_files) { [File.expand_path(__FILE__)].to_set } - example_group.run(reporter) - examples = example_group.examples - send_notification :dump_summary, summary_notification(1, examples, examples, [], 0) - output.string + def output_from_running(example_group) + allow(RSpec.configuration).to receive(:loaded_spec_files) { RSpec::Core::Set.new([File.expand_path(__FILE__)]) } + example_group.run(reporter) + examples = example_group.examples + failed = examples.select { |e| e.execution_result.status == :failed } + send_notification :dump_summary, summary_notification(1, examples, failed, [], 0) + formatter_output.string + end end end @@ -75,8 +124,8 @@ def run_all_and_dump_failures run_all_and_dump_failures - expect(output.string).to match(/group name example name/m) - expect(output.string).to match(/(\s+)expected: \"that\"\n\1 got: \"this\"/m) + expect(formatter_output.string).to match(/group name example name/m) + expect(formatter_output.string).to match(/(\s+)expected: \"that\"\n\1 got: \"this\"/m) end context "with an exception without a message" do @@ -108,7 +157,7 @@ def run_all_and_dump_failures exception = Class.new(StandardError).new group.example("example name") { raise exception } run_all_and_dump_failures - expect(output.string).to include('(anonymous error class)') + expect(formatter_output.string).to include('(anonymous error class)') end end @@ -116,7 +165,7 @@ def run_all_and_dump_failures it "does not show the error class" do group.example("example name") { raise NameError.new('foo') } run_all_and_dump_failures - expect(output.string).to match(/NameError/m) + expect(formatter_output.string).to match(/NameError/m) end end @@ -125,7 +174,7 @@ def run_all_and_dump_failures it "runs without encountering an encoding exception" do group.example("Mixing encodings, e.g. UTF-8: © and Binary") { raise "Error: \xC2\xA9".force_encoding("ASCII-8BIT") } run_all_and_dump_failures - expect(output.string).to match(/RuntimeError:\n\s+Error: \?\?/m) # ?? because the characters dont encode properly + expect(formatter_output.string).to match(/RuntimeError:\n\s+Error: \?\?/m) # ?? because the characters dont encode properly end end end @@ -134,7 +183,7 @@ def run_all_and_dump_failures it "does not show the error class" do group.example("example name") { expect("this").to eq("that") } run_all_and_dump_failures - expect(output.string).not_to match(/RSpec/m) + expect(formatter_output.string).not_to match(/RSpec/m) end end @@ -142,7 +191,7 @@ def run_all_and_dump_failures it "does not show the error class" do group.example("example name") { expect("this").to receive("that") } run_all_and_dump_failures - expect(output.string).not_to match(/RSpec/m) + expect(formatter_output.string).not_to match(/RSpec/m) end end @@ -158,7 +207,7 @@ def run_all_and_dump_failures run_all_and_dump_failures - expect(output.string.lines).to include(a_string_ending_with( + expect(formatter_output.string.lines).to include(a_string_ending_with( 'Shared Example Group: "foo bar" called from ' + "#{RSpec::Core::Metadata.relative_path(__FILE__)}:#{line}\n" )) @@ -177,7 +226,7 @@ def run_all_and_dump_failures run_all_and_dump_failures - expect(output.string.lines).to include(a_string_ending_with( + expect(formatter_output.string.lines).to include(a_string_ending_with( 'Shared Example Group: "foo bar" called from ' + "./spec/rspec/core/formatters/base_text_formatter_spec.rb:#{line}\n" )) @@ -200,7 +249,7 @@ def run_all_and_dump_failures run_all_and_dump_failures - expect(output.string.lines.grep(/Shared Example Group/)).to match [ + expect(formatter_output.string.lines.grep(/Shared Example Group/)).to match [ a_string_ending_with( 'Shared Example Group: "inner" called from ' + "./spec/rspec/core/formatters/base_text_formatter_spec.rb:#{inner_line}\n" @@ -224,7 +273,7 @@ def run_all_and_dump_failures config.success_color = :cyan end send_notification :dump_summary, summary_notification(0, examples(1), [], [], 0) - expect(output.string).to include("\e[36m") + expect(formatter_output.string).to include("\e[36m") end end end diff --git a/spec/rspec/core/formatters/documentation_formatter_spec.rb b/spec/rspec/core/formatters/documentation_formatter_spec.rb index 43eac1beaa..bb7fe165cf 100644 --- a/spec/rspec/core/formatters/documentation_formatter_spec.rb +++ b/spec/rspec/core/formatters/documentation_formatter_spec.rb @@ -18,15 +18,17 @@ def execution_result(values) it "numbers the failures" do send_notification :example_failed, example_notification( double("example 1", :description => "first example", + :full_description => "group first example", :execution_result => execution_result(:status => :failed, :exception => Exception.new) )) send_notification :example_failed, example_notification( double("example 2", :description => "second example", + :full_description => "group second example", :execution_result => execution_result(:status => :failed, :exception => Exception.new) )) - expect(output.string).to match(/first example \(FAILED - 1\)/m) - expect(output.string).to match(/second example \(FAILED - 2\)/m) + expect(formatter_output.string).to match(/first example \(FAILED - 1\)/m) + expect(formatter_output.string).to match(/second example \(FAILED - 2\)/m) end it "represents nested group using hierarchy tree" do @@ -45,7 +47,7 @@ def execution_result(values) group.run(reporter) - expect(output.string).to eql(" + expect(formatter_output.string).to eql(" root context 1 nested example 1.1 @@ -68,7 +70,7 @@ def execution_result(values) group.run(reporter) - expect(output.string).to eql(" + expect(formatter_output.string).to eql(" root nested example 1 @@ -77,8 +79,8 @@ def execution_result(values) ") end - # The backrace is slightly different on JRuby so we skip there. - it 'produces the expected full output', :unless => RUBY_PLATFORM == 'java' do + # The backtrace is slightly different on JRuby/Rubinius so we skip there. + it 'produces the expected full output', :if => RSpec::Support::Ruby.mri? do output = run_example_specs_with_formatter("doc") output.gsub!(/ +$/, '') # strip trailing whitespace @@ -90,8 +92,8 @@ def execution_result(values) |pending command with block format | with content that would fail | is pending (PENDING: No reason given) - | with content that would pass - | fails (FAILED - 1) + | behaves like shared + | is marked as pending but passes (FAILED - 1) | |passing spec | passes diff --git a/spec/rspec/core/formatters/exception_presenter_spec.rb b/spec/rspec/core/formatters/exception_presenter_spec.rb new file mode 100644 index 0000000000..0fb0e51618 --- /dev/null +++ b/spec/rspec/core/formatters/exception_presenter_spec.rb @@ -0,0 +1,477 @@ +require 'pathname' + +module RSpec::Core + RSpec.describe Formatters::ExceptionPresenter do + include FormatterSupport + + let(:example) { new_example } + let(:presenter) { Formatters::ExceptionPresenter.new(exception, example) } + + before do + allow(example.execution_result).to receive(:exception) { exception } + example.metadata[:absolute_file_path] = __FILE__ + end + + describe "#fully_formatted" do + line_num = __LINE__ + 2 + let(:exception) { instance_double(Exception, :message => "Boom\nBam", :backtrace => [ "#{__FILE__}:#{line_num}"]) } + # The failure happened here! + + it "formats the exception with all the normal details" do + expect(presenter.fully_formatted(1)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 1) Example + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it "indents properly when given a multiple-digit failure index" do + expect(presenter.fully_formatted(100)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 100) Example + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it "allows the caller to specify additional indentation" do + presenter = Formatters::ExceptionPresenter.new(exception, example, :indentation => 4) + + expect(presenter.fully_formatted(1)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 1) Example + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it 'passes the indentation on to the `:detail_formatter` lambda so it can align things' do + detail_formatter = Proc.new { "Some Detail" } + + presenter = Formatters::ExceptionPresenter.new(exception, example, :indentation => 4, + :detail_formatter => detail_formatter) + expect(presenter.fully_formatted(1)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 1) Example + | Some Detail + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it 'allows the caller to omit the description' do + presenter = Formatters::ExceptionPresenter.new(exception, example, + :detail_formatter => Proc.new { "Detail!" }, + :description_formatter => Proc.new { }) + + expect(presenter.fully_formatted(1)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 1) Detail! + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it 'allows the failure/error line to be used as the description' do + presenter = Formatters::ExceptionPresenter.new(exception, example, :description_formatter => lambda { |p| p.failure_slash_error_line }) + + expect(presenter.fully_formatted(1)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 1) Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + EOS + end + + it 'allows a caller to specify extra details that are added to the bottom' do + presenter = Formatters::ExceptionPresenter.new( + exception, example, :extra_detail_formatter => lambda do |failure_number, colorizer, indentation| + "#{indentation}extra detail for failure: #{failure_number}\n" + end + ) + + expect(presenter.fully_formatted(2)).to eq(<<-EOS.gsub(/^ +\|/, '')) + | + | 2) Example + | Failure/Error: # The failure happened here! + | Boom + | Bam + | # ./spec/rspec/core/formatters/exception_presenter_spec.rb:#{line_num} + | extra detail for failure: 2 + EOS + end + end + + describe "#read_failed_line" do + def read_failed_line + presenter.send(:read_failed_line) + end + + context "when backtrace is a heterogeneous language stack trace" do + let(:exception) do + instance_double(Exception, :backtrace => [ + "at Object.prototypeMethod (foo:331:18)", + "at Array.forEach (native)", + "at a_named_javascript_function (/some/javascript/file.js:39:5)", + "/some/line/of/ruby.rb:14" + ]) + end + + it "is handled gracefully" do + expect { read_failed_line }.not_to raise_error + end + end + + context "when backtrace will generate a security error" do + let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) } + + it "is handled gracefully" do + with_safe_set_to_level_that_triggers_security_errors do + expect { read_failed_line }.not_to raise_error + end + end + end + + context "when ruby reports a bogus line number in the stack trace" do + let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:10000000"]) } + + it "reports the filename and that it was unable to find the matching line" do + expect(read_failed_line).to include("Unable to find matching line") + end + end + + context "when ruby reports a file that does not exist" do + let(:file) { "#{__FILE__}/blah.rb" } + let(:exception) { instance_double(Exception, :backtrace => [ "#{file}:1"]) } + + it "reports the filename and that it was unable to find the matching line" do + example.metadata[:absolute_file_path] = file + expect(read_failed_line).to include("Unable to find #{file} to read failed line") + end + end + + context "when the stacktrace includes relative paths (which can happen when using `rspec/autorun` and running files through `ruby`)" do + let(:relative_file) { Pathname(__FILE__).relative_path_from(Pathname(Dir.pwd)) } + line = __LINE__ + let(:exception) { instance_double(Exception, :backtrace => ["#{relative_file}:#{line}"]) } + + it 'still finds the backtrace line' do + expect(read_failed_line).to include("line = __LINE__") + end + end + + context "when String alias to_int to_i" do + before do + String.class_exec do + alias :to_int :to_i + end + end + + after do + String.class_exec do + undef to_int + end + end + + let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) } + + it "doesn't hang when file exists" do + expect(read_failed_line.strip).to eql( + %Q[let(:exception) { instance_double(Exception, :backtrace => [ "\#{__FILE__}:\#{__LINE__}"]) }]) + end + end + end + end + + RSpec.describe Formatters::ExceptionPresenter::Factory::CommonBacktraceTruncater do + def truncate(parent, child) + described_class.new(parent).with_truncated_backtrace(child) + end + + def exception_with(backtrace) + exception = Exception.new + exception.set_backtrace(backtrace) + exception + end + + it 'returns an exception with the common part truncated' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 ] + child = exception_with %w[ file_1.rb:3 file_1.rb:9 foo.rb:1 bar.rb:2 car.rb:7 ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ file_1.rb:3 file_1.rb:9 ] + end + + it 'ignores excess lines in the top of the parent trace that the child does not have' do + parent = exception_with %w[ foo.rb:1 foo.rb:2 foo.rb:3 bar.rb:2 car.rb:7 ] + child = exception_with %w[ file_1.rb:3 file_1.rb:9 bar.rb:2 car.rb:7 ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ file_1.rb:3 file_1.rb:9 ] + end + + it 'does not truncate anything if the parent has excess lines at the bottom of the trace' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 bazz.rb:9 ] + child = exception_with %w[ file_1.rb:3 file_1.rb:9 foo.rb:1 bar.rb:2 car.rb:7 ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ file_1.rb:3 file_1.rb:9 foo.rb:1 bar.rb:2 car.rb:7 ] + end + + it 'does not mutate the provided exception' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 ] + child = exception_with %w[ file_1.rb:3 file_1.rb:9 foo.rb:1 bar.rb:2 car.rb:7 ] + + expect { truncate(parent, child) }.not_to change(child, :backtrace) + end + + it 'returns an exception with all the same attributes (except backtrace) as the provided one' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 ] + + my_custom_exception_class = Class.new(StandardError) do + attr_accessor :foo, :bar + end + + child = my_custom_exception_class.new("Some Message") + child.foo = 13 + child.bar = 20 + child.set_backtrace(%w[ foo.rb:1 ]) + + truncated = truncate(parent, child) + + expect(truncated).to have_attributes( + :message => "Some Message", + :foo => 13, + :bar => 20 + ) + end + + it 'handles child exceptions that have a blank array for the backtrace' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 ] + child = exception_with %w[ ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ ] + end + + it 'handles child exceptions that have `nil` for the backtrace' do + parent = exception_with %w[ foo.rb:1 bar.rb:2 car.rb:7 ] + child = Exception.new + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to be_nil + end + + it 'handles parent exceptions that have a blank array for the backtrace' do + parent = exception_with %w[ ] + child = exception_with %w[ foo.rb:1 ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ foo.rb:1 ] + end + + it 'handles parent exceptions that have `nil` for the backtrace' do + parent = Exception.new + child = exception_with %w[ foo.rb:1 ] + + truncated = truncate(parent, child) + + expect(truncated.backtrace).to eq %w[ foo.rb:1 ] + end + + it 'returns the original exception object (not a dup) when there is no need to update the backtrace' do + parent = exception_with %w[ bar.rb:1 ] + child = exception_with %w[ foo.rb:1 ] + + truncated = truncate(parent, child) + + expect(truncated).to be child + end + end + + RSpec.shared_examples_for "a class satisfying the common multiple exception error interface" do + def new_failure(*a) + RSpec::Expectations::ExpectationNotMetError.new(*a) + end + + def new_error(*a) + StandardError.new(*a) + end + + it 'allows you to keep track of failures and other errors in order' do + mee = new_multiple_exception_error + + f1 = new_failure + e1 = new_error + f2 = new_failure + + expect { mee.add(f1) }.to change(mee, :failures).to [f1] + expect { mee.add(e1) }.to change(mee, :other_errors).to [e1] + expect { mee.add(f2) }.to change(mee, :failures).to [f1, f2] + + expect(mee.all_exceptions).to eq([f1, e1, f2]) + end + + it 'allows you to add exceptions of an anonymous class' do + mee = new_multiple_exception_error + + expect { + mee.add(Class.new(StandardError).new) + }.to change(mee.other_errors, :count).by 1 + end + + it 'ignores `Pending::PendingExampleFixedError` since it does not represent a real failure but rather the lack of one' do + mee = new_multiple_exception_error + + expect { + mee.add Pending::PendingExampleFixedError.new + }.to avoid_changing(mee.other_errors, :count). + and avoid_changing(mee.all_exceptions, :count). + and avoid_changing(mee.failures, :count) + end + + it 'is tagged with a common module so it is clear it has the interface for multiple exceptions' do + expect(MultipleExceptionError::InterfaceTag).to be === new_multiple_exception_error + end + end + + RSpec.describe RSpec::Expectations::ExpectationNotMetError do + include_examples "a class satisfying the common multiple exception error interface" do + def new_multiple_exception_error + failure_aggregator = RSpec::Expectations::FailureAggregator.new(nil, {}) + RSpec::Expectations::MultipleExpectationsNotMetError.new(failure_aggregator) + end + end + end + + RSpec.describe MultipleExceptionError do + include_examples "a class satisfying the common multiple exception error interface" do + def new_multiple_exception_error + MultipleExceptionError.new + end + end + + it 'supports the same interface as `RSpec::Expectations::MultipleExpectationsNotMetError`' do + skip "Skipping to allow an rspec-expectations PR to add a new method and remain green" if ENV['NEW_MUTLI_EXCEPTION_METHOD'] + + aggregate_failures { } # force autoload + + interface = RSpec::Expectations::MultipleExpectationsNotMetError.instance_methods - Exception.instance_methods + expect(MultipleExceptionError.new).to respond_to(*interface) + end + + it 'allows you to instantiate it with an initial list of exceptions' do + mee = MultipleExceptionError.new(f1 = new_failure, e1 = new_error) + + expect(mee).to have_attributes( + :failures => [f1], + :other_errors => [e1], + :all_exceptions => [f1, e1] + ) + end + + specify 'the `message` implementation provides all failure messages, but is not well formatted because we do not actually use it' do + mee = MultipleExceptionError.new( + new_failure("failure 1"), + new_error("error 1") + ) + + expect(mee.message).to include("failure 1", "error 1") + end + + it 'provides a description of the exception counts, correctly categorized as failures or exceptions' do + mee = MultipleExceptionError.new + + expect { + mee.add new_failure + mee.add new_error + }.to change(mee, :exception_count_description). + from("0 failures"). + to("1 failure and 1 other error") + + expect { + mee.add new_failure + mee.add new_error + }.to change(mee, :exception_count_description). + to("2 failures and 2 other errors") + end + + it 'provides a summary of the exception counts' do + mee = MultipleExceptionError.new + + expect { + mee.add new_failure + mee.add new_error + }.to change(mee, :summary). + from("Got 0 failures"). + to("Got 1 failure and 1 other error") + + expect { + mee.add new_failure + mee.add new_error + }.to change(mee, :summary). + to("Got 2 failures and 2 other errors") + end + + it 'presents the same aggregation metadata that an `:aggregate_failures`-tagged example produces' do + ex = nil + + RSpec.describe do + ex = it "", :aggregate_failures do + expect(1).to eq(2) + expect(1).to eq(2) + end + end.run + + expected_metadata = ex.exception.aggregation_metadata + expect(MultipleExceptionError.new.aggregation_metadata).to eq(expected_metadata) + end + + describe "::InterfaceTag.for" do + def value_for(ex) + described_class::InterfaceTag.for(ex) + end + + context "when given an `#{described_class.name}`" do + it 'returns the provided error' do + ex = MultipleExceptionError.new + expect(value_for ex).to be ex + end + end + + context "when given an `RSpec::Expectations::MultipleExpectationsNotMetError`" do + it 'returns the provided error' do + failure_aggregator = RSpec::Expectations::FailureAggregator.new(nil, {}) + ex = RSpec::Expectations::MultipleExpectationsNotMetError.new(failure_aggregator) + + expect(value_for ex).to be ex + end + end + + context "when given any other exception" do + it 'wraps it in a `RSpec::Expectations::MultipleExceptionError`' do + ex = StandardError.new + expect(value_for ex).to be_a(MultipleExceptionError).and have_attributes(:all_exceptions => [ex]) + end + end + end + end +end diff --git a/spec/rspec/core/formatters/fallback_message_formatter_spec.rb b/spec/rspec/core/formatters/fallback_message_formatter_spec.rb new file mode 100644 index 0000000000..0ba66325f7 --- /dev/null +++ b/spec/rspec/core/formatters/fallback_message_formatter_spec.rb @@ -0,0 +1,18 @@ +require 'rspec/core/reporter' +require 'rspec/core/formatters/fallback_message_formatter' + +module RSpec::Core::Formatters + RSpec.describe FallbackMessageFormatter do + include FormatterSupport + + describe "#message" do + it 'writes the message to the output' do + expect { + send_notification :message, message_notification('Custom Message') + }.to change { formatter_output.string }. + from(excluding 'Custom Message'). + to(including 'Custom Message') + end + end + end +end diff --git a/spec/rspec/core/formatters/helpers_spec.rb b/spec/rspec/core/formatters/helpers_spec.rb index 6fe50854b0..c789662b45 100644 --- a/spec/rspec/core/formatters/helpers_spec.rb +++ b/spec/rspec/core/formatters/helpers_spec.rb @@ -46,6 +46,12 @@ end end + context '= 70' do + it "returns 'x minute, x0 seconds' formatted string" do + expect(helper.format_duration(70)).to eq("1 minute 10 seconds") + end + end + context 'with mathn loaded' do include MathnIntegrationSupport @@ -82,6 +88,12 @@ expect(helper.format_seconds(1.00000000001)).to eq("1") end end + + context "70" do + it "doesn't strip of meaningful trailing zeros" do + expect(helper.format_seconds(70)).to eq("70") + end + end end context "second and greater times" do diff --git a/spec/rspec/core/formatters/html_formatted.html b/spec/rspec/core/formatters/html_formatted.html index 9c3b4cd0d0..0558fbef75 100644 --- a/spec/rspec/core/formatters/html_formatted.html +++ b/spec/rspec/core/formatters/html_formatted.html @@ -296,18 +296,18 @@

RSpec Code Examples

-
with content that would pass
+
behaves like shared
- fails + is marked as pending but passes n.nnnns
Expected example to fail since it is pending, but it passed.
-
./spec/rspec/core/resources/formatter_specs.rb:16
-
14
-15  context "with content that would pass" do
-16    it "fails" do
-17      pending
-18      expect(1).to eq(1)
+
./spec/rspec/core/resources/formatter_specs.rb:4
+
2
+3RSpec.shared_examples_for "shared" do
+4  it "is marked as pending but passes" do
+5    pending
+6    expect(1).to eq(1)
@@ -333,12 +333,12 @@

RSpec Code Examples

(compared using ==)
-
./spec/rspec/core/resources/formatter_specs.rb:31
-
29RSpec.describe "failing spec" do
-30  it "fails" do
-31    expect(1).to eq(2)
-32  end
-33end
+
./spec/rspec/core/resources/formatter_specs.rb:33
+
31RSpec.describe "failing spec" do
+32  it "fails" do
+33    expect(1).to eq(2)
+34  end
+35end
@@ -351,7 +351,7 @@

RSpec Code Examples

n.nnnns
foo
-
./spec/rspec/core/resources/formatter_specs.rb:39
+
./spec/rspec/core/resources/formatter_specs.rb:41
-1# Couldn't get snippet for (erb)
diff --git a/spec/rspec/core/formatters/json_formatter_spec.rb b/spec/rspec/core/formatters/json_formatter_spec.rb index 4e42215be1..d84c430d3c 100644 --- a/spec/rspec/core/formatters/json_formatter_spec.rb +++ b/spec/rspec/core/formatters/json_formatter_spec.rb @@ -13,7 +13,13 @@ RSpec.describe RSpec::Core::Formatters::JsonFormatter do include FormatterSupport - it "outputs json (brittle high level functional test)" do + it "can be loaded via `--format json`" do + output = run_example_specs_with_formatter("json", false) + parsed = JSON.parse(output) + expect(parsed.keys).to include("examples", "summary", "summary_line") + end + + it "outputs expected json (brittle high level functional test)" do group = RSpec.describe("one apiece") do it("succeeds") { expect(1).to eq 1 } it("fails") { fail "eek" } @@ -35,6 +41,7 @@ this_file = relative_path(__FILE__) expected = { + :version => RSpec::Core::Version::STRING, :examples => [ { :description => "succeeds", @@ -42,7 +49,8 @@ :status => "passed", :file_path => this_file, :line_number => succeeding_line, - :run_time => formatter.output_hash[:examples][0][:run_time] + :run_time => formatter.output_hash[:examples][0][:run_time], + :pending_message => nil, }, { :description => "fails", @@ -51,11 +59,12 @@ :file_path => this_file, :line_number => failing_line, :run_time => formatter.output_hash[:examples][1][:run_time], + :pending_message => nil, :exception => { :class => "RuntimeError", :message => "eek", :backtrace => failing_backtrace - } + }, }, { :description => "pends", @@ -63,7 +72,8 @@ :status => "pending", :file_path => this_file, :line_number => pending_line, - :run_time => formatter.output_hash[:examples][2][:run_time] + :run_time => formatter.output_hash[:examples][2][:run_time], + :pending_message => "world peace", }, ], :summary => { @@ -75,7 +85,7 @@ :summary_line => "3 examples, 1 failure, 1 pending" } expect(formatter.output_hash).to eq expected - expect(output.string).to eq expected.to_json + expect(formatter_output.string).to eq expected.to_json end describe "#stop" do @@ -87,9 +97,11 @@ describe "#close" do it "outputs the results as a JSON string" do - expect(output.string).to eq "" + expect(formatter_output.string).to eq "" send_notification :close, null_notification - expect(output.string).to eq("{}") + expect(formatter_output.string).to eq({ + :version => RSpec::Core::Version::STRING + }.to_json) end end @@ -121,8 +133,8 @@ def profile *groups end before do + setup_profiler formatter - config.profile_examples = 10 end context "with one example group" do @@ -151,17 +163,19 @@ def profile *groups context "with multiple example groups", :slow do before do - example_clock = class_double(RSpec::Core::Time, :now => RSpec::Core::Time.now + 0.5) + start = Time.utc(2015, 6, 10, 12, 30) + now = start + + allow(RSpec::Core::Time).to receive(:now) { now } group1 = RSpec.describe("slow group") do - example("example") do |example| - # make it look slow without actually taking up precious time - example.clock = example_clock - end + example("example") { } + after { now += 100 } end group2 = RSpec.describe("fast group") do example("example 1") { } example("example 2") { } + after { now += 1 } end profile group1, group2 end @@ -171,10 +185,10 @@ def profile *groups end it "provides information" do - expect(formatter.output_hash[:profile][:groups].first.keys).to match_array([:total_time, :count, :description, :average, :location]) + expect(formatter.output_hash[:profile][:groups].first.keys).to match_array([:total_time, :count, :description, :average, :location, :start]) end - it "ranks the example groups by average time" do + it "ranks the example groups by average time" do |ex| expect(formatter.output_hash[:profile][:groups].first[:description]).to eq("slow group") end end diff --git a/spec/rspec/core/formatters/profile_formatter_spec.rb b/spec/rspec/core/formatters/profile_formatter_spec.rb index ee53ce3b42..9009b181aa 100644 --- a/spec/rspec/core/formatters/profile_formatter_spec.rb +++ b/spec/rspec/core/formatters/profile_formatter_spec.rb @@ -4,6 +4,7 @@ include FormatterSupport def profile *groups + setup_profiler groups.each { |group| group.run(reporter) } examples = groups.map(&:examples).flatten total_time = examples.map { |e| e.execution_result.run_time }.inject(&:+) @@ -15,24 +16,20 @@ def profile *groups shared_examples_for "profiles examples" do it "names the example" do - expect(output.string).to match(/group example/m) + expect(formatter_output.string).to match(/group example/m) end it "prints the time" do - expect(output.string).to match(/0(\.\d+)? seconds/) + expect(formatter_output.string).to match(/0(\.\d+)? seconds/) end it "prints the path" do filename = __FILE__.split(File::SEPARATOR).last - expect(output.string).to match(/#{filename}\:#{example_line_number}/) + expect(formatter_output.string).to match(/#{filename}\:#{example_line_number}/) end it "prints the percentage taken from the total runtime" do - expect(output.string).to match(/, 100.0% of total time\):/) - end - - it "doesn't profile a single example group" do - expect(output.string).not_to match(/slowest example groups/) + expect(formatter_output.string).to match(/, 100.0% of total time\):/) end end @@ -50,18 +47,23 @@ def profile *groups end it_should_behave_like "profiles examples" + + it "doesn't profile a single example group" do + expect(formatter_output.string).not_to match(/slowest example groups/) + end end context "with multiple example groups" do before do example_clock = class_double(RSpec::Core::Time, :now => RSpec::Core::Time.now + 0.5) + @slow_group_line_number = __LINE__ + 1 group1 = RSpec.describe("slow group") do example("example") do |example| # make it look slow without actually taking up precious time example.clock = example_clock end - example_line_number = __LINE__ - 4 + example_line_number = __LINE__ - 4 end group2 = RSpec.describe("fast group") do example("example 1") { } @@ -70,29 +72,23 @@ def profile *groups profile group1, group2 end + it_should_behave_like "profiles examples" + it "prints the slowest example groups" do - expect(output.string).to match(/slowest example groups/) + expect(formatter_output.string).to match(/slowest example groups/) end it "prints the time" do - expect(output.string).to match(/0(\.\d+)? seconds/) + expect(formatter_output.string).to match(/0(\.\d+)? seconds/) end it "ranks the example groups by average time" do - expect(output.string).to match(/slow group(.*)fast group/m) + expect(formatter_output.string).to match(/slow group(.*)fast group/m) end - end - it "depends on parent_groups to get the top level example group" do - ex = nil - group = RSpec.describe - group.describe("group 2") do - describe "group 3" do - ex = example("nested example 1") - end + it "prints the location of the slow groups" do + expect(formatter_output.string).to include("#{RSpec::Core::Metadata.relative_path __FILE__}:#{@slow_group_line_number}") end - - expect(ex.example_group.parent_groups.last).to eq(group) end end end diff --git a/spec/rspec/core/formatters/progress_formatter_spec.rb b/spec/rspec/core/formatters/progress_formatter_spec.rb index a92a9fdb0f..22d587f1ec 100644 --- a/spec/rspec/core/formatters/progress_formatter_spec.rb +++ b/spec/rspec/core/formatters/progress_formatter_spec.rb @@ -10,38 +10,38 @@ it 'prints a . on example_passed' do send_notification :example_passed, example_notification - expect(output.string).to eq(".") + expect(formatter_output.string).to eq(".") end it 'prints a * on example_pending' do send_notification :example_pending, example_notification - expect(output.string).to eq("*") + expect(formatter_output.string).to eq("*") end it 'prints a F on example_failed' do send_notification :example_failed, example_notification - expect(output.string).to eq("F") + expect(formatter_output.string).to eq("F") end it "produces standard summary without pending when pending has a 0 count" do send_notification :dump_summary, summary_notification(0.00001, examples(2), [], [], 0) - expect(output.string).to match(/^\n/) - expect(output.string).to match(/2 examples, 0 failures/i) - expect(output.string).not_to match(/0 pending/i) + expect(formatter_output.string).to match(/^\n/) + expect(formatter_output.string).to match(/2 examples, 0 failures/i) + expect(formatter_output.string).not_to match(/0 pending/i) end it "pushes nothing on start" do #start already sent - expect(output.string).to eq("") + expect(formatter_output.string).to eq("") end it "pushes nothing on start dump" do send_notification :start_dump, null_notification - expect(output.string).to eq("\n") + expect(formatter_output.string).to eq("\n") end - # The backrace is slightly different on JRuby so we skip there. - it 'produces the expected full output', :unless => RUBY_PLATFORM == 'java' do + # The backtrace is slightly different on JRuby/Rubinius so we skip there. + it 'produces the expected full output', :if => RSpec::Support::Ruby.mri? do output = run_example_specs_with_formatter("progress") output.gsub!(/ +$/, '') # strip trailing whitespace diff --git a/spec/rspec/core/formatters/snippet_extractor_spec.rb b/spec/rspec/core/formatters/snippet_extractor_spec.rb index 5227e2532f..1bece6c724 100644 --- a/spec/rspec/core/formatters/snippet_extractor_spec.rb +++ b/spec/rspec/core/formatters/snippet_extractor_spec.rb @@ -9,16 +9,41 @@ module Formatters end it "falls back on a default message when it doesn't find the file" do - expect(RSpec::Core::Formatters::SnippetExtractor.new.lines_around("blech", 8)).to eq("# Couldn't get snippet for blech") + expect(RSpec::Core::Formatters::SnippetExtractor.new.lines_around("blech", 8)).to eq("# Couldn't get snippet for blech") end it "falls back on a default message when it gets a security error" do message = nil - safely do + with_safe_set_to_level_that_triggers_security_errors do message = RSpec::Core::Formatters::SnippetExtractor.new.lines_around("blech", 8) end expect(message).to eq("# Couldn't get snippet for blech") end + + describe "snippet extraction" do + let(:snippet) do + SnippetExtractor.new.snippet(["#{__FILE__}:#{__LINE__}"]) + end + + before do + # `send` is required for 1.8.7... + @orig_converter = SnippetExtractor.send(:class_variable_get, :@@converter) + end + + after do + SnippetExtractor.send(:class_variable_set, :@@converter, @orig_converter) + end + + it 'suggests you install coderay when it cannot be loaded' do + SnippetExtractor.send(:class_variable_set, :@@converter, SnippetExtractor::NullConverter) + + expect(snippet).to include("Install the coderay gem") + end + + it 'does not suggest installing coderay normally' do + expect(snippet).to exclude("Install the coderay gem") + end + end end end end diff --git a/spec/rspec/core/formatters_spec.rb b/spec/rspec/core/formatters_spec.rb index 1cf61feaba..6c3aa5eb23 100644 --- a/spec/rspec/core/formatters_spec.rb +++ b/spec/rspec/core/formatters_spec.rb @@ -41,7 +41,7 @@ module RSpec::Core::Formatters context "when a legacy formatter is added with RSpec::LegacyFormatters" do formatter_class = Struct.new(:output) - let(:formatter) { double "formatter", :notifications => notifications } + let(:formatter) { double "formatter", :notifications => notifications, :output => output } let(:notifications) { [:a, :b, :c] } before do @@ -57,6 +57,14 @@ module RSpec::Core::Formatters expect(reporter).to receive(:register_listener).with(formatter, *notifications) loader.add formatter_class, output end + + it "will ignore duplicate legacy formatters" do + loader.add formatter_class, output + expect(reporter).to_not receive(:register_listener) + expect { + loader.add formatter_class, output + }.not_to change { loader.formatters.length } + end end context "when a legacy formatter is added without RSpec::LegacyFormatters" do @@ -117,6 +125,7 @@ module RSpec::Core::Formatters before { loader.add :documentation, output } it "doesn't add the formatter for the same output target" do + expect(reporter).to_not receive(:register_listener) expect { loader.add :documentation, output }.not_to change { loader.formatters.length } @@ -130,28 +139,57 @@ module RSpec::Core::Formatters end end - describe "#setup_default", "with profiling enabled" do + describe "#setup_default" do let(:setup_default) { loader.setup_default output, output } - before do - allow(RSpec.configuration).to receive(:profile_examples?) { true } + context "with a formatter that implements #message" do + it 'doesnt add a fallback formatter' do + allow(reporter).to receive(:registered_listeners).with(:message) { [:json] } + setup_default + expect(loader.formatters).to exclude( + an_instance_of ::RSpec::Core::Formatters::FallbackMessageFormatter + ) + end end - context "without an existing profile formatter" do - it "will add the profile formatter" do - allow(reporter).to receive(:registered_listeners).with(:dump_profile) { [] } - setup_default - expect(loader.formatters.last).to be_a ::RSpec::Core::Formatters::ProfileFormatter + context "without a formatter that implements #message" do + it 'adds a fallback for message output' do + allow(reporter).to receive(:registered_listeners).with(:message) { [] } + expect { + setup_default + }.to change { loader.formatters }. + from( excluding an_instance_of ::RSpec::Core::Formatters::FallbackMessageFormatter ). + to( including an_instance_of ::RSpec::Core::Formatters::FallbackMessageFormatter ) end end - context "when a formatter that implement #dump_profile is added" do - it "wont add the profile formatter" do - allow(reporter).to receive(:registered_listeners).with(:dump_profile) { [:json] } - setup_default - expect( - loader.formatters.map(&:class) - ).to_not include ::RSpec::Core::Formatters::ProfileFormatter + context "with profiling enabled" do + before do + allow(reporter).to receive(:registered_listeners).with(:message) { [:json] } + allow(RSpec.configuration).to receive(:profile_examples?) { true } + end + + context "without an existing profile formatter" do + it "will add the profile formatter" do + allow(reporter).to receive(:registered_listeners).with(:dump_profile) { [] } + expect(reporter).to receive(:setup_profiler) + expect { + setup_default + }.to change { loader.formatters }. + from( excluding an_instance_of ::RSpec::Core::Formatters::ProfileFormatter ). + to( including an_instance_of ::RSpec::Core::Formatters::ProfileFormatter ) + end + end + + context "when a formatter that implement #dump_profile is added" do + it "wont add the profile formatter" do + allow(reporter).to receive(:registered_listeners).with(:dump_profile) { [:json] } + expect(reporter).to receive(:setup_profiler) + setup_default + expect( + loader.formatters.map(&:class) + ).to_not include ::RSpec::Core::Formatters::ProfileFormatter + end end end end diff --git a/spec/rspec/core/hooks_spec.rb b/spec/rspec/core/hooks_spec.rb index d77b6f4b95..7c55fce4b9 100644 --- a/spec/rspec/core/hooks_spec.rb +++ b/spec/rspec/core/hooks_spec.rb @@ -74,6 +74,15 @@ def hook_collection_for(position, scope) instance.hooks.run(type, scope, double("Example").as_null_object) }.not_to yield_control end + + if scope == :example + it "yields the example as an argument to the hook" do + group = RSpec.describe + ex = group.example { } + + expect { |p| group.send(type, scope, &p); group.run }.to yield_with_args(ex) + end + end end end end diff --git a/spec/rspec/core/memoized_helpers_spec.rb b/spec/rspec/core/memoized_helpers_spec.rb index 7c9ad11105..3869127b63 100644 --- a/spec/rspec/core/memoized_helpers_spec.rb +++ b/spec/rspec/core/memoized_helpers_spec.rb @@ -1,3 +1,5 @@ +require 'thread_order' + module RSpec::Core RSpec.describe MemoizedHelpers do before(:each) { RSpec.configuration.configure_expectation_framework } @@ -363,6 +365,107 @@ def not_ok?; false; end expect(subject).to eq(3) end end + + describe 'threadsafety', :threadsafe => true do + before(:all) { eq 1 } # explanation: https://github.com/rspec/rspec-core/pull/1858/files#r25411166 + + context 'when not threadsafe' do + # would be nice to not set this on the global + before { RSpec.configuration.threadsafe = false } + + it 'can wind up overwriting the previous memoized value (but if you don\'t need threadsafety, this is faster)' do + describe_successfully do + let!(:order) { ThreadOrder.new } + after { order.apocalypse! :join } + + let :memoized_value do + if order.current == :second + :second_access + else + order.pass_to :second, :resume_on => :exit + :first_access + end + end + + example do + order.declare(:second) { expect(memoized_value).to eq :second_access } + expect(memoized_value).to eq :first_access + end + end + end + end + + context 'when threadsafe' do + before(:context) { RSpec.configuration.threadsafe = true } + specify 'first thread to access determines the return value' do + describe_successfully do + let!(:order) { ThreadOrder.new } + after { order.apocalypse! :join } + + let :memoized_value do + if order.current == :second + :second_access + else + order.pass_to :second, :resume_on => :sleep + :first_access + end + end + + example do + order.declare(:second) { expect(memoized_value).to eq :first_access } + expect(memoized_value).to eq :first_access + end + end + end + + specify 'memoized block will only be evaluated once' do + describe_successfully do + let!(:order) { ThreadOrder.new } + after { order.apocalypse! } + before { @previously_accessed = false } + + let :memoized_value do + raise 'Called multiple times!' if @previously_accessed + @previously_accessed = true + order.pass_to :second, :resume_on => :sleep + end + + example do + order.declare(:second) { memoized_value } + memoized_value + order.join_all + end + end + end + + specify 'memoized blocks prevent other threads from accessing, even when it is accesssed in a superclass' do + describe_successfully do + let!(:order) { ThreadOrder.new } + after { order.apocalypse! :join } + + let!(:calls) { {:parent => 0, :child => 0} } + let(:memoized_value) do + calls[:parent] += 1 + order.pass_to :second, :resume_on => :sleep + 'parent' + end + + describe 'child' do + let :memoized_value do + calls[:child] += 1 + "#{super()}/child" + end + + example do + order.declare(:second) { expect(memoized_value).to eq 'parent/child' } + expect(memoized_value).to eq 'parent/child' + expect(calls).to eq :parent => 1, :child => 1 + end + end + end + end + end + end end RSpec.describe "#let" do diff --git a/spec/rspec/core/metadata_filter_spec.rb b/spec/rspec/core/metadata_filter_spec.rb index 98b77b32fb..a35a2c61e9 100644 --- a/spec/rspec/core/metadata_filter_spec.rb +++ b/spec/rspec/core/metadata_filter_spec.rb @@ -107,6 +107,37 @@ def filter_applies?(key, value, metadata) }.to raise_error(ArgumentError) end + context "with an :ids filter" do + it 'matches examples with a matching id and rerun_file_path' do + metadata = { :scoped_id => "1:2", :rerun_file_path => "some/file" } + expect(filter_applies?(:ids, { "some/file" => ["1:2"] }, metadata)).to be true + end + + it 'does not match examples without a matching id' do + metadata = { :scoped_id => "1:2", :rerun_file_path => "some/file" } + expect(filter_applies?(:ids, { "some/file" => ["1:3"] }, metadata)).to be false + end + + it 'does not match examples without a matching rerun_file_path' do + metadata = { :scoped_id => "1:2", :rerun_file_path => "some/file" } + expect(filter_applies?(:ids, { "some/file_2" => ["1:2"] }, metadata)).to be false + end + + it 'matches the scoped id from a parent example group' do + metadata = { :scoped_id => "1:2", :rerun_file_path => "some/file", :example_group => { :scoped_id => "1" } } + expect(filter_applies?(:ids, { "some/file" => ["1"] }, metadata)).to be true + expect(filter_applies?(:ids, { "some/file" => ["2"] }, metadata)).to be false + end + + it 'matches only on entire id segments so (1 is not treated as a parent group of 11)' do + metadata = { :scoped_id => "1:2", :rerun_file_path => "some/file", :example_group => { :scoped_id => "1" } } + expect(filter_applies?(:ids, { "some/file" => ["1"] }, metadata)).to be true + + metadata = { :scoped_id => "11", :rerun_file_path => "some/file" } + expect(filter_applies?(:ids, { "some/file" => ["1"] }, metadata)).to be false + end + end + context "with a nested hash" do it 'matches when the nested entry matches' do metadata = { :foo => { :bar => "words" } } diff --git a/spec/rspec/core/metadata_spec.rb b/spec/rspec/core/metadata_spec.rb index b646a7dfc7..04ff8d0e99 100644 --- a/spec/rspec/core/metadata_spec.rb +++ b/spec/rspec/core/metadata_spec.rb @@ -15,7 +15,9 @@ module Core end # I have no idea what line = line.sub(/\A([^:]+:\d+)$/, '\\1') is supposed to do it "gracefully returns nil if run in a secure thread" do - safely do + # Ensure our call to `File.expand_path` is not cached as that is the insecure operation. + Metadata.instance_eval { @relative_path_regex = nil } + with_safe_set_to_level_that_triggers_security_errors do value = Metadata.relative_path(".") # on some rubies, File.expand_path is not a security error, so accept "." as well expect([nil, "."]).to include(value) @@ -31,6 +33,15 @@ module Core end + specify 'RESERVED_KEYS contains all keys assigned by RSpec (and vice versa)' do + group = RSpec.describe("group") + example = group.example("example") { } + nested_group = group.describe("nested") + + assigned_keys = group.metadata.keys | example.metadata.keys | nested_group.metadata.keys + expect(RSpec::Core::Metadata::RESERVED_KEYS).to match_array(assigned_keys) + end + context "when created" do Metadata::RESERVED_KEYS.each do |key| it "prohibits :#{key} as a hash key for an example group" do @@ -159,6 +170,106 @@ def metadata_for(*args) end end + describe ":last_run_status" do + it 'assigns it by looking up configuration.last_run_statuses[id]' do + looked_up_ids = [] + last_run_statuses = Hash.new do |hash, id| + looked_up_ids << id + "some_status" + end + + allow(RSpec.configuration).to receive(:last_run_statuses).and_return(last_run_statuses) + example = RSpec.describe.example + + expect(example.metadata[:last_run_status]).to eq("some_status") + expect(looked_up_ids).to eq [example.id] + end + end + + describe ":id" do + define :have_id_with do |scoped_id| + expected_id = "#{Metadata.relative_path(__FILE__)}[#{scoped_id}]" + + match do |group_or_example| + group_or_example.metadata[:scoped_id] == scoped_id && + group_or_example.id == expected_id + end + + failure_message do |group_or_example| + "expected #{group_or_example.inspect}\n" \ + " to have id: #{expected_id}\n" \ + " but had id: #{group_or_example.id}\n" \ + " and have scoped id: #{scoped_id}\n" \ + " but had scoped id: #{group_or_example.metadata[:scoped_id]}" + end + end + + context "on a top-level group" do + it "is set to file[]" do + expect(RSpec.describe).to have_id_with("1") + expect(RSpec.describe).to have_id_with("2") + end + + it "starts the count at 1 for each file" do + instance_eval <<-EOS, "spec_1.rb", 1 + $group_1 = RSpec.describe + $group_2 = RSpec.describe + EOS + + instance_eval <<-EOS, "spec_2.rb", 1 + $group_3 = RSpec.describe + $group_4 = RSpec.describe + EOS + + expect($group_1.id).to end_with("spec_1.rb[1]") + expect($group_2.id).to end_with("spec_1.rb[2]") + expect($group_3.id).to end_with("spec_2.rb[1]") + expect($group_4.id).to end_with("spec_2.rb[2]") + end + end + + context "on a nested group" do + it "is set to file[:]" do + top_level_group = RSpec.describe + expect(top_level_group.describe).to have_id_with("1:1") + expect(top_level_group.describe).to have_id_with("1:2") + end + end + + context "on an example" do + it "is set to file[:]" do + group = RSpec.describe + expect(group.example).to have_id_with("1:1") + expect(group.example).to have_id_with("1:2") + end + end + + context "when examples are interleaved with example groups" do + it "counts both when assigning the index" do + group = RSpec.describe + expect(group.example ).to have_id_with("1:1") + expect(group.describe).to have_id_with("1:2") + expect(group.example ).to have_id_with("1:3") + expect(group.example ).to have_id_with("1:4") + expect(group.describe).to have_id_with("1:5") + end + end + + context "on an example defined in a shared group defined in a separate file" do + it "uses the host group's file name as the prefix" do + # Using eval in order to make ruby think this got defined in another file. + instance_eval <<-EOS, "some/external/file.rb", 1 + RSpec.shared_examples "shared" do + example { } + end + EOS + + group = RSpec.describe { include_examples "shared" } + expect(group.examples.first.id).to start_with(Metadata.relative_path(__FILE__)) + end + end + end + describe ":shared_group_inclusion_backtrace" do context "for an example group" do it "is not set since we do not yet need it internally (but we can add it in the future if needed)" do diff --git a/spec/rspec/core/notifications_spec.rb b/spec/rspec/core/notifications_spec.rb index 83428f5026..93fbc41f51 100644 --- a/spec/rspec/core/notifications_spec.rb +++ b/spec/rspec/core/notifications_spec.rb @@ -1,85 +1,302 @@ require 'rspec/core/notifications' -require 'pathname' RSpec.describe "FailedExampleNotification" do include FormatterSupport - let(:notification) { ::RSpec::Core::Notifications::FailedExampleNotification.new(example) } + let(:example) { new_example(:status => :failed) } + exception_line = __LINE__ + 1 + let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{exception_line}"], :message => 'Test exception') } + let(:notification) { ::RSpec::Core::Notifications::ExampleNotification.for(example) } before do + example.execution_result.exception = exception example.metadata[:absolute_file_path] = __FILE__ end - # ported from `base_formatter_spec` should be refactored by final - describe "#read_failed_line" do - context "when backtrace is a heterogeneous language stack trace" do - let(:exception) do - instance_double(Exception, :backtrace => [ - "at Object.prototypeMethod (foo:331:18)", - "at Array.forEach (native)", - "at a_named_javascript_function (/some/javascript/file.js:39:5)", - "/some/line/of/ruby.rb:14" - ]) - end + it 'provides a description' do + expect(notification.description).to eq(example.full_description) + end + + it 'provides `colorized_formatted_backtrace`, which formats the backtrace and colorizes it' do + allow(RSpec.configuration).to receive(:color_enabled?).and_return(true) + expect(notification.colorized_formatted_backtrace).to eq(["\e[36m# #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{exception_line}\e[0m"]) + end + + describe "fully formatted failure output" do + def fully_formatted(*args) + notification.fully_formatted(1, *args) + end + + def dedent(string) + string.gsub(/^ +\|/, '') + end - it "is handled gracefully" do - expect { notification.send(:read_failed_line) }.not_to raise_error + # ANSI codes aren't easy to read in failure output, so use tags instead + class TagColorizer + def self.wrap(text, code_or_symbol) + "<#{code_or_symbol}>#{text}" end end - context "when backtrace will generate a security error" do - let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) } + context "when the exception is a MultipleExpectationsNotMetError" do + RSpec::Matchers.define :fail_with_description do |desc| + match { false } + description { desc } + failure_message { "expected pass, but #{desc}" } + end + + def capture_and_normalize_aggregation_error + yield + rescue RSpec::Expectations::MultipleExpectationsNotMetError => failure + normalize_backtraces(failure) + failure + end + + def normalize_backtraces(failure) + failure.all_exceptions.each do |exception| + if exception.is_a?(RSpec::Expectations::MultipleExpectationsNotMetError) + normalize_backtraces(exception) + end - it "is handled gracefully" do - safely do - expect { notification.send(:read_failed_line) }.not_to raise_error + normalize_one_backtrace(exception) end + + normalize_one_backtrace(failure) end - end - context "when ruby reports a bogus line number in the stack trace" do - let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:10000000"]) } + def normalize_one_backtrace(exception) + line = exception.backtrace.find { |l| l.include?(__FILE__) } + exception.set_backtrace([ line.sub(/:in .*$/, '') ]) + end - it "reports the filename and that it was unable to find the matching line" do - expect(notification.send(:read_failed_line)).to include("Unable to find matching line") + let(:aggregate_line) { __LINE__ + 3 } + let(:exception) do + capture_and_normalize_aggregation_error do + aggregate_failures("multiple expectations") do + expect(1).to fail_with_description("foo") + expect(1).to fail_with_description("bar") + end + end end - end - context "when the stacktrace includes relative paths (which can happen when using `rspec/autorun` and running files through `ruby`)" do - let(:relative_file) { Pathname(__FILE__).relative_path_from(Pathname(Dir.pwd)) } - line = __LINE__ - let(:exception) { instance_double(Exception, :backtrace => ["#{relative_file}:#{line}"]) } + it 'provides a summary composed of example description, failure count and aggregate backtrace' do + expect(fully_formatted.lines.first(5)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1) Example + | Got 2 failures from failure aggregation block "multiple expectations". + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line} + | + EOS + end - it 'still finds the backtrace line' do - expect(notification.send(:read_failed_line)).to include("line = __LINE__") + it 'lists each individual expectation failure, with a backtrace relative to the aggregation block' do + expect(fully_formatted.lines.to_a.last(8)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1.1) Failure/Error: expect(1).to fail_with_description("foo") + | expected pass, but foo + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 1} + | + | 1.2) Failure/Error: expect(1).to fail_with_description("bar") + | expected pass, but bar + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 2} + EOS + end + + it 'uses the `failure` color in the summary output' do + expect(fully_formatted(TagColorizer)).to include( + 'Got 2 failures from failure aggregation block "multiple expectations".' + ) + end + + it 'uses the `failure` color for the sub-failure messages' do + expect(fully_formatted(TagColorizer)).to include( + ' expected pass, but foo', + ' expected pass, but bar' + ) end - end - context "when String alias to_int to_i" do - before do - String.class_exec do - alias :to_int :to_i + context "due to using `:aggregate_failures` metadata" do + let(:exception) do + ex = nil + RSpec.describe do + ex = it "", :aggregate_failures do + expect(1).to fail_with_description("foo") + expect(1).to fail_with_description("bar") + end + end.run + + capture_and_normalize_aggregation_error { raise ex.execution_result.exception } + end + + it 'uses an alternate format for the exception summary to avoid confusing references to the aggregation block or stack trace' do + expect(fully_formatted.lines.first(4)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1) Example + | Got 2 failures: + | + EOS end end - after do - String.class_exec do - undef to_int + context "when the failure happened in a shared example group" do + before do |ex| + example.metadata[:shared_group_inclusion_backtrace] << RSpec::Core::SharedExampleGroupInclusionStackFrame.new( + "Stuff", "./some_shared_group_file.rb:13" + ) + end + + it "includes the shared group backtrace as part of the aggregate failure backtrace" do + expect(fully_formatted.lines.first(6)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1) Example + | Got 2 failures from failure aggregation block "multiple expectations". + | Shared Example Group: "Stuff" called from ./some_shared_group_file.rb:13 + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line} + | + EOS + end + + it "does not include the shared group backtrace in the sub-failure backtraces" do + expect(fully_formatted.lines.to_a.last(8)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1.1) Failure/Error: expect(1).to fail_with_description("foo") + | expected pass, but foo + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 1} + | + | 1.2) Failure/Error: expect(1).to fail_with_description("bar") + | expected pass, but bar + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 2} + EOS end end - let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) } + context "when `aggregate_failures` is used in nested fashion" do + let(:aggregate_line) { __LINE__ + 3 } + let(:exception) do + capture_and_normalize_aggregation_error do + aggregate_failures("outer") do + expect(1).to fail_with_description("foo") + + aggregate_failures("inner") do + expect(2).to fail_with_description("bar") + expect(3).to fail_with_description("baz") + end - it "doesn't hang when file exists" do - expect(notification.send(:read_failed_line).strip).to eql( - %Q[let(:exception) { instance_double(Exception, :backtrace => [ "\#{__FILE__}:\#{__LINE__}"]) }]) + expect(1).to fail_with_description("qux") + end + end + end + + it 'recursively formats the nested aggregated failures' do + expect(fully_formatted).to eq(dedent <<-EOS) + | + | 1) Example + | Got 3 failures from failure aggregation block "outer". + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line} + | + | 1.1) Failure/Error: expect(1).to fail_with_description("foo") + | expected pass, but foo + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 1} + | + | 1.2) Got 2 failures from failure aggregation block "inner". + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 3} + | + | 1.2.1) Failure/Error: expect(2).to fail_with_description("bar") + | expected pass, but bar + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 4} + | + | 1.2.2) Failure/Error: expect(3).to fail_with_description("baz") + | expected pass, but baz + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 5} + | + | 1.3) Failure/Error: expect(1).to fail_with_description("qux") + | expected pass, but qux + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 8} + EOS + end + end + + context "when there are failures and other errors" do + let(:aggregate_line) { __LINE__ + 3 } + let(:exception) do + capture_and_normalize_aggregation_error do + aggregate_failures("multiple expectations") do + expect(1).to fail_with_description("foo") + raise "boom" + end + end + end + + it 'lists both types in the exception listing' do + expect(fully_formatted.lines.to_a.last(9)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1.1) Failure/Error: expect(1).to fail_with_description("foo") + | expected pass, but foo + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 1} + | + | 1.2) Failure/Error: raise "boom" + | RuntimeError: + | boom + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line + 2} + EOS + end + end + + context "in a pending spec" do + before do + example.execution_result.status = :pending + example.execution_result.pending_message = 'Some pending reason' + example.execution_result.pending_exception = exception + example.execution_result.exception = nil + end + + it 'includes both the pending message and aggregate summary' do + expect(fully_formatted.lines.first(6)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1) Example + | # Some pending reason + | Got 2 failures from failure aggregation block "multiple expectations". + | # #{RSpec::Core::Metadata.relative_path(__FILE__)}:#{aggregate_line} + | + EOS + end + + it 'uses the `pending` color in the summary output' do + expect(fully_formatted(TagColorizer)).to include( + 'Got 2 failures from failure aggregation block "multiple expectations".' + ) + end + + it 'uses the `pending` color for the sub-failure messages' do + expect(fully_formatted(TagColorizer)).to include( + ' expected pass, but foo', + ' expected pass, but bar' + ) + end end + end + context "when the exception is a MultipleExceptionError" do + let(:sub_failure_1) { StandardError.new("foo").tap { |e| e.set_backtrace([]) } } + let(:sub_failure_2) { StandardError.new("bar").tap { |e| e.set_backtrace([]) } } + let(:exception) { RSpec::Core::MultipleExceptionError.new(sub_failure_1, sub_failure_2) } + + it "lists each sub-failure, just like with MultipleExpectationsNotMetError" do + expect(fully_formatted.lines.to_a.last(8)).to eq(dedent(<<-EOS).lines.to_a) + | + | 1.1) Failure/Error: Unable to find matching line from backtrace + | StandardError: + | foo + | + | 1.2) Failure/Error: Unable to find matching line from backtrace + | StandardError: + | bar + EOS + end end end describe '#message_lines' do - let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"], :message => 'Test exception') } let(:example_group) { class_double(RSpec::Core::ExampleGroup, :metadata => {}, :parent_groups => [], :location => "#{__FILE__}:#{__LINE__}") } before do @@ -100,5 +317,39 @@ expect(lines[0]).to match %r{\AFailure\/Error} expect(lines[1]).to match %r{\A\s*Test exception\z} end + + if String.method_defined?(:encoding) + it "returns failures_lines with invalid bytes replace by '?'" do + message_with_invalid_byte_sequence = + "\xEF \255 \xAD I have bad bytes".force_encoding(Encoding::UTF_8) + allow(exception).to receive(:message). + and_return(message_with_invalid_byte_sequence) + + lines = notification.message_lines + expect(lines[0]).to match %r{\AFailure\/Error} + expect(lines[1].strip).to eq("? ? ? I have bad bytes") + end + end + end +end + +module RSpec::Core::Notifications + RSpec.describe ExamplesNotification do + include FormatterSupport + + describe "#notifications" do + it 'returns an array of notification objects for all the examples' do + reporter = RSpec::Core::Reporter.new(RSpec.configuration) + example = new_example + + reporter.example_started(example) + reporter.example_passed(example) + + notification = ExamplesNotification.new(reporter) + expect(notification.notifications).to match [ + an_instance_of(ExampleNotification) & an_object_having_attributes(:example => example) + ] + end + end end end diff --git a/spec/rspec/core/option_parser_spec.rb b/spec/rspec/core/option_parser_spec.rb index f387dbb3f6..2ca8bc615b 100644 --- a/spec/rspec/core/option_parser_spec.rb +++ b/spec/rspec/core/option_parser_spec.rb @@ -1,3 +1,5 @@ +require 'rspec/core/project_initializer' + module RSpec::Core RSpec.describe OptionParser do before do @@ -11,21 +13,39 @@ module RSpec::Core end end - it "does not parse empty args" do - parser = Parser.new - expect(OptionParser).not_to receive(:new) - parser.parse([]) + context "when given empty args" do + it "does not parse them" do + expect(OptionParser).not_to receive(:new) + Parser.parse([]) + end + + it "still returns a `:files_or_directories_to_run` entry since callers expect that" do + expect( + Parser.parse([]) + ).to eq(:files_or_directories_to_run => []) + end + end + + it 'does not mutate the provided args array' do + args = %w[ --require foo ] + expect { Parser.parse(args) }.not_to change { args } end it "proposes you to use --help and returns an error on incorrect argument" do - parser = Parser.new - option = "--my_wrong_arg" + parser = Parser.new([option = "--my_wrong_arg"]) expect(parser).to receive(:abort) do |msg| expect(msg).to include('use --help', option) end - parser.parse([option]) + parser.parse + end + + it 'treats additional arguments as `:files_or_directories_to_run`' do + options = Parser.parse(%w[ path/to/spec.rb --fail-fast spec/unit -Ibar 1_spec.rb:23 ]) + expect(options).to include( + :files_or_directories_to_run => %w[ path/to/spec.rb spec/unit 1_spec.rb:23 ] + ) end { @@ -36,22 +56,22 @@ module RSpec::Core }.each do |long, shorts| shorts.each do |option| it "won't parse #{option} as a shorthand for #{long}" do - parser = Parser.new + parser = Parser.new([option]) expect(parser).to receive(:abort) do |msg| expect(msg).to include('use --help', option) end - parser.parse([option]) + parser.parse end end end it "won't display invalid options in the help output" do def generate_help_text - parser = Parser.new + parser = Parser.new(["--help"]) allow(parser).to receive(:exit) - parser.parse(["--help"]) + parser.parse end useless_lines = /^\s*--?\w+\s*$\n/ @@ -59,6 +79,33 @@ def generate_help_text expect { generate_help_text }.to_not output(useless_lines).to_stdout end + %w[ -v --version ].each do |option| + describe option do + it "prints the version and exits" do + parser = Parser.new([option]) + expect(parser).to receive(:exit) + + expect { + parser.parse + }.to output("#{RSpec::Core::Version::STRING}\n").to_stdout + end + end + end + + describe "--init" do + it "initializes a project and exits" do + project_init = instance_double(ProjectInitializer) + allow(ProjectInitializer).to receive_messages(:new => project_init) + + parser = Parser.new(["--init"]) + + expect(project_init).to receive(:run).ordered + expect(parser).to receive(:exit).ordered + + parser.parse + end + end + describe "-I" do it "sets the path" do options = Parser.parse(%w[-I path/to/foo]) @@ -123,6 +170,34 @@ def generate_help_text end end + describe "--only-failures" do + it 'is equivalent to `--tag last_run_status:failed`' do + tag = Parser.parse(%w[ --tag last_run_status:failed ]) + only_failures = Parser.parse(%w[ --only-failures ]) + + expect(only_failures).to include(tag) + end + end + + describe "--next-failure" do + it 'is equivalent to `--tag last_run_status:failed --fail-fast --order defined`' do + long_form = Parser.parse(%w[ --tag last_run_status:failed --fail-fast --order defined ]) + next_failure = Parser.parse(%w[ --next-failure ]) + + expect(next_failure).to include(long_form) + end + + it 'does not force `--order defined` over a specified `--seed 1234` option that comes before it' do + options = Parser.parse(%w[ --seed 1234 --next-failure ]) + expect(options).to include(:order => "rand:1234") + end + + it 'does not force `--order defined` over a specified `--seed 1234` option that comes after it' do + options = Parser.parse(%w[ --next-failure --seed 1234 ]) + expect(options).to include(:order => "rand:1234") + end + end + %w[--example -e].each do |option| describe option do it "escapes the arg" do diff --git a/spec/rspec/core/ordering_spec.rb b/spec/rspec/core/ordering_spec.rb index 2cda655899..579150c8ba 100644 --- a/spec/rspec/core/ordering_spec.rb +++ b/spec/rspec/core/ordering_spec.rb @@ -11,8 +11,12 @@ module Ordering describe '.order' do subject { described_class.new(configuration) } + def item(n) + instance_double(Example, :id => "./some_spec.rb[1:#{n}]") + end + let(:configuration) { RSpec::Core::Configuration.new } - let(:items) { 10.times.map { |n| n } } + let(:items) { 10.times.map { |n| item(n) } } let(:shuffled_items) { subject.order items } it 'shuffles the items randomly' do @@ -26,6 +30,37 @@ module Ordering end end + def order_with(seed) + configuration.seed = seed + subject.order(items) + end + + it 'has a good distribution', :slow do + orderings = 1.upto(1000).map do |seed| + order_with(seed) + end.uniq + + # Here we are making sure that our hash function used for ordering has a + # good distribution. Each seed produces a deterministic order and we want + # 99%+ of 1000 to be different. + expect(orderings.count).to be > 990 + end + + context "when given a subset of a list that was previously shuffled with the same seed" do + it "orders that subset the same as it was ordered before" do + all_items = 20.times.map { |n| item(n) } + + all_shuffled = subject.order(all_items) + expect(all_shuffled).not_to eq(all_items) + + last_half = all_items[10, 10] + last_half_shuffled = subject.order(last_half) + last_half_from_all_shuffled = all_shuffled.select { |i| last_half.include?(i) } + + expect(last_half_from_all_shuffled.map(&:id)).to eq(last_half_shuffled.map(&:id)) + end + end + context 'given randomization has been seeded explicitly' do before { @seed = srand } after { srand @seed } @@ -69,7 +104,7 @@ module Ordering end it 'returns true if the random orderer has been used' do - registry.fetch(:random).order([1, 2]) + registry.fetch(:random).order([RSpec.describe, RSpec.describe]) expect(registry.used_random_seed?).to be true end end diff --git a/spec/rspec/core/profiler_spec.rb b/spec/rspec/core/profiler_spec.rb new file mode 100644 index 0000000000..7bd370a5cb --- /dev/null +++ b/spec/rspec/core/profiler_spec.rb @@ -0,0 +1,82 @@ +require 'rspec/core/profiler' + +RSpec.describe 'RSpec::Core::Profiler' do + let(:profiler) { RSpec::Core::Profiler.new } + + it 'starts with an empty hash of example_groups' do + expect(profiler.example_groups).to be_empty.and be_a Hash + end + + context 'when hooked into the reporter' do + include FormatterSupport + + let(:description ) { "My Group" } + let(:now) { ::Time.utc(2015, 6, 2) } + + before do + allow(::RSpec::Core::Time).to receive(:now) { now } + end + + let(:group) { RSpec.describe "My Group" } + + describe '#example_group_started' do + it 'records example groups start time and description' do + expect { + profiler.example_group_started group_notification group + }.to change { profiler.example_groups[group] }. + from(a_hash_excluding(:start, :description)). + to(a_hash_including(:start => now, :description => description)) + end + + context "when the group is not a top-level group" do + let(:group) { super().describe "nested" } + + it 'no-ops since we only consider top-level groups for profiling' do + expect { + profiler.example_group_started group_notification group + }.not_to change(profiler, :example_groups) + end + end + end + + describe '#example_group_finished' do + before do + profiler.example_group_started group_notification group + allow(::RSpec::Core::Time).to receive(:now) { now + 1 } + end + + it 'records example groups total time and description' do + expect { + profiler.example_group_finished group_notification group + }.to change { profiler.example_groups[group] }. + from(a_hash_excluding(:total_time)). + to(a_hash_including(:total_time => 1.0)) + end + + context "when the group is not a top-level group" do + let(:group) { super().describe "nested" } + + it 'no-ops since we only consider top-level groups for profiling' do + expect { + profiler.example_group_finished group_notification group + }.not_to change(profiler, :example_groups) + end + end + end + + describe '#example_started' do + let(:example) { new_example } + before do + allow(example).to receive(:example_group) { group } + allow(group).to receive(:parent_groups) { [group] } + profiler.example_group_started group_notification group + end + + it 'increments the count of examples for its parent group' do + expect { + profiler.example_started example_notification example + }.to change { profiler.example_groups[group][:count] }.by 1 + end + end + end +end diff --git a/spec/rspec/core/rake_task_spec.rb b/spec/rspec/core/rake_task_spec.rb index 772dea4162..f376da7b2f 100644 --- a/spec/rspec/core/rake_task_spec.rb +++ b/spec/rspec/core/rake_task_spec.rb @@ -47,6 +47,13 @@ def spec_command end end + context "on windows, with a quote in the name", :if => RSpec::Support::OS.windows? do + it "renders rspec quoted, with quote escaped" do + task.rspec_path = "/foo'bar/exe/rspec" + expect(spec_command).to include(%q|'/foo\'bar/exe/rspec'|) + end + end + context "with ruby options" do it "renders them before the rspec path" do task.ruby_opts = "-w" @@ -73,6 +80,23 @@ def spec_command end end + context "when `failure_message` is configured" do + before do + allow(task).to receive(:exit) + task.failure_message = "Bad news" + end + + it 'prints it if the RSpec run failed' do + task.ruby_opts = '-e "exit(1);" ;#' + expect { task.run_task false }.to output(/Bad news/).to_stdout + end + + it 'does not print it if the RSpec run succeeded' do + task.ruby_opts = '-e "exit(0);" ;#' + expect { task.run_task false }.not_to output(/Bad/).to_stdout + end + end + context 'with custom exit status' do def silence_output(&block) expect(&block).to output(anything).to_stdout.and output(anything).to_stderr diff --git a/spec/rspec/core/random_spec.rb b/spec/rspec/core/random_spec.rb deleted file mode 100644 index f1e8ea9c9a..0000000000 --- a/spec/rspec/core/random_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -module RSpec - module Core - RSpec.describe RandomNumberGenerator do - it 'is a random number generator' do - random = described_class.new - - expect([Fixnum, Bignum]).to include random.seed.class - expect(random.rand).to be_a Float - - rands = [] - 100.times do - rands << random.rand - end - - expect(rands.uniq.count).to be > 90 - end - - it 'produces the same results given the same seed' do - seed = rand(999) - - random = described_class.new(seed) - - expect(random.seed).to eq seed - - expected = [] - 5.times do - expected << random.rand(999) - end - - 10.times do - random = described_class.new(seed) - - expect(random.seed).to eq seed - - actual = [] - 5.times do - actual << random.rand(999) - end - - expect(actual).to eq expected - end - end - end - end -end diff --git a/spec/rspec/core/reentrant_mutex_spec.rb b/spec/rspec/core/reentrant_mutex_spec.rb new file mode 100644 index 0000000000..e3fae36ebf --- /dev/null +++ b/spec/rspec/core/reentrant_mutex_spec.rb @@ -0,0 +1,30 @@ +require 'rspec/core/reentrant_mutex' +require 'thread_order' + +# There are no assertions specifically +# They are pass if they don't deadlock +RSpec.describe RSpec::Core::ReentrantMutex do + let!(:mutex) { described_class.new } + let!(:order) { ThreadOrder.new } + after { order.apocalypse! } + + it 'can repeatedly synchronize within the same thread' do + mutex.synchronize { mutex.synchronize { } } + end + + it 'locks other threads out while in the synchronize block' do + order.declare(:before) { mutex.synchronize { } } + order.declare(:within) { mutex.synchronize { } } + order.declare(:after) { mutex.synchronize { } } + + order.pass_to :before, :resume_on => :exit + mutex.synchronize { order.pass_to :within, :resume_on => :sleep } + order.pass_to :after, :resume_on => :exit + end + + it 'resumes the next thread once all its synchronize blocks have completed' do + order.declare(:thread) { mutex.synchronize { } } + mutex.synchronize { order.pass_to :thread, :resume_on => :sleep } + order.join_all + end +end diff --git a/spec/rspec/core/reporter_spec.rb b/spec/rspec/core/reporter_spec.rb index 0c195a7db4..f3730c127a 100644 --- a/spec/rspec/core/reporter_spec.rb +++ b/spec/rspec/core/reporter_spec.rb @@ -25,6 +25,7 @@ module RSpec::Core it "dumps the failure summary after the profile and deprecation summary so failures don't scroll off the screen and get missed" do config.profile_examples = 10 formatter = instance_double("RSpec::Core::Formatter::ProgressFormatter") + reporter.setup_profiler reporter.register_listener(formatter, :dump_summary, :dump_profile, :deprecation_summary) expect(formatter).to receive(:deprecation_summary).ordered @@ -50,13 +51,14 @@ module RSpec::Core reporter.start 3, (start_time + 5) end - it 'notifies formatters of the seed used' do + it 'notifies the formatter of the seed used before notifing of start' do formatter = double("formatter") reporter.register_listener formatter, :seed - - expect(formatter).to receive(:seed).with( + reporter.register_listener formatter, :start + expect(formatter).to receive(:seed).ordered.with( an_object_having_attributes(:seed => config.seed, :seed_used? => config.seed_used?) ) + expect(formatter).to receive(:start).ordered reporter.start 1 end end @@ -65,6 +67,7 @@ module RSpec::Core it "passes messages to that formatter" do formatter = double("formatter", :example_started => nil) reporter.register_listener formatter, :example_started + example = new_example expect(formatter).to receive(:example_started) do |notification| expect(notification.example).to eq example @@ -120,6 +123,7 @@ module RSpec::Core context "given multiple formatters" do it "passes messages to all formatters" do formatters = (1..2).map { double("formatter", :example_started => nil) } + example = new_example formatters.each do |formatter| expect(formatter).to receive(:example_started) do |notification| @@ -172,6 +176,60 @@ module RSpec::Core end end + describe "#publish" do + let(:listener) { double("listener", :custom => nil) } + before do + reporter.register_listener listener, :custom, :start + end + + it 'will send custom events to registered listeners' do + expect(listener).to receive(:custom).with(RSpec::Core::Notifications::NullNotification) + reporter.publish :custom + end + + it 'will raise when encountering RSpec standard events' do + expect { reporter.publish :start }.to raise_error( + StandardError, + a_string_including("not internal RSpec ones") + ) + end + + it 'will ignore event names sent as strings' do + expect(listener).not_to receive(:custom) + reporter.publish "custom" + end + + it 'will provide a custom notification object based on the options hash' do + expect(listener).to receive(:custom).with( + an_object_having_attributes(:my_data => :value) + ) + reporter.publish :custom, :my_data => :value + end + end + + describe "#abort_with" do + before { allow(reporter).to receive(:exit!) } + + it "publishes the message and notifies :close" do + listener = double("Listener") + reporter.register_listener(listener, :message, :close) + stream = StringIO.new + + allow(listener).to receive(:message) { |n| stream << n.message } + allow(listener).to receive(:close) { stream.close } + + reporter.register_listener(listener) + reporter.abort_with("Booom!", 1) + + expect(stream).to have_attributes(:string => "Booom!").and be_closed + end + + it "exits with the provided exit code" do + reporter.abort_with("msg", 13) + expect(reporter).to have_received(:exit!).with(13) + end + end + describe "timing" do before do config.start_time = start_time diff --git a/spec/rspec/core/resources/fail_on_load_spec.rb_ b/spec/rspec/core/resources/fail_on_load_spec.rb_ new file mode 100644 index 0000000000..6255adcb03 --- /dev/null +++ b/spec/rspec/core/resources/fail_on_load_spec.rb_ @@ -0,0 +1,9 @@ +# Deliberately named *.rb_ to avoid being loaded except when specified + +RSpec.describe "A group" do + puts "About to call misspelled method" + contex "misspelled" do + it "fails" do + end + end +end diff --git a/spec/rspec/core/resources/formatter_specs.rb b/spec/rspec/core/resources/formatter_specs.rb index cdf47830d8..ef4b9403d4 100644 --- a/spec/rspec/core/resources/formatter_specs.rb +++ b/spec/rspec/core/resources/formatter_specs.rb @@ -1,5 +1,12 @@ # Deliberately named _specs.rb to avoid being loaded except when specified +RSpec.shared_examples_for "shared" do + it "is marked as pending but passes" do + pending + expect(1).to eq(1) + end +end + RSpec.describe "pending spec with no implementation" do it "is pending" end @@ -12,12 +19,7 @@ end end - context "with content that would pass" do - it "fails" do - pending - expect(1).to eq(1) - end - end + it_behaves_like "shared" end RSpec.describe "passing spec" do diff --git a/spec/rspec/core/resources/inconsistently_ordered_specs.rb b/spec/rspec/core/resources/inconsistently_ordered_specs.rb new file mode 100644 index 0000000000..213d01a4b3 --- /dev/null +++ b/spec/rspec/core/resources/inconsistently_ordered_specs.rb @@ -0,0 +1,12 @@ +# Deliberately named _specs.rb to avoid being loaded except when specified + +RSpec.configure do |c| + c.register_ordering(:global, &:shuffle) +end + +10.times do |i| + RSpec.describe "Group #{i}" do + it("passes") { } + it("fails") { fail } + end +end diff --git a/spec/rspec/core/ruby_project_spec.rb b/spec/rspec/core/ruby_project_spec.rb index 985a721231..7e49fedb7d 100644 --- a/spec/rspec/core/ruby_project_spec.rb +++ b/spec/rspec/core/ruby_project_spec.rb @@ -31,11 +31,11 @@ def expect_ascend(source_path, *yielded_paths) end it "works with a normal path" do - expect_ascend("/var//ponies/", "/var/ponies", "/var", "/") + expect_ascend("/var/ponies", "/var/ponies", "/var", "/") end it "works with a path with a trailing slash" do - expect_ascend("/var//ponies/", "/var/ponies", "/var", "/") + expect_ascend("/var/ponies/", "/var/ponies", "/var", "/") end it "works with a path with double slashes" do diff --git a/spec/rspec/core/runner_spec.rb b/spec/rspec/core/runner_spec.rb index 82c917c9a4..830fbc2e9d 100644 --- a/spec/rspec/core/runner_spec.rb +++ b/spec/rspec/core/runner_spec.rb @@ -21,10 +21,30 @@ module RSpec::Core end end - describe 'at_exit' do + describe '.autorun' do + before do + @original_ivars = Hash[ Runner.instance_variables.map do |ivar| + [ivar, Runner.instance_variable_get(ivar)] + end ] + end + + after do + (@original_ivars.keys | Runner.instance_variables).each do |ivar| + if @original_ivars.key?(ivar) + Runner.instance_variable_set(ivar, @original_ivars[ivar]) + else + # send is necessary for 1.8.7 + Runner.send(:remove_instance_variable, ivar) + end + end + end + it 'sets an at_exit hook if none is already set' do - allow(RSpec::Core::Runner).to receive(:autorun_disabled?).and_return(false) - allow(RSpec::Core::Runner).to receive(:installed_at_exit?).and_return(false) + Runner.instance_eval do + @autorun_disabled = false + @installed_at_exit = false + end + allow(RSpec::Core::Runner).to receive(:running_in_drb?).and_return(false) allow(RSpec::Core::Runner).to receive(:invoke) expect(RSpec::Core::Runner).to receive(:at_exit) @@ -32,14 +52,65 @@ module RSpec::Core end it 'does not set the at_exit hook if it is already set' do - allow(RSpec::Core::Runner).to receive(:autorun_disabled?).and_return(false) - allow(RSpec::Core::Runner).to receive(:installed_at_exit?).and_return(true) + Runner.instance_eval do + @autorun_disabled = false + @installed_at_exit = true + end + allow(RSpec::Core::Runner).to receive(:running_in_drb?).and_return(false) expect(RSpec::Core::Runner).to receive(:at_exit).never RSpec::Core::Runner.autorun end end + describe "at_exit hook" do + before { allow(Runner).to receive(:invoke) } + + it 'normally runs the spec suite' do + Runner.perform_at_exit + expect(Runner).to have_received(:invoke) + end + + it 'does not run the suite if an error triggered the exit' do + begin + raise "boom" + rescue + Runner.perform_at_exit + end + + expect(Runner).not_to have_received(:invoke) + end + + it 'stil runs the suite if a `SystemExit` occurs since that is caused by `Kernel#exit`' do + begin + exit + rescue SystemExit + Runner.perform_at_exit + end + + expect(Runner).to have_received(:invoke) + end + end + + describe "interrupt handling" do + before { allow(Runner).to receive(:exit!) } + + it 'prints a message the first time, then exits the second time' do + expect { + Runner.handle_interrupt + }.to output(/shutting down/).to_stderr_from_any_process & + change { RSpec.world.wants_to_quit }.from(a_falsey_value).to(true) + + expect(Runner).not_to have_received(:exit!) + + expect { + Runner.handle_interrupt + }.not_to output.to_stderr_from_any_process + + expect(Runner).to have_received(:exit!) + end + end + # This is intermittently slow because this method calls out to the network # interface. describe ".running_in_drb?", :slow do @@ -236,6 +307,36 @@ def run_specs end end + describe "persistence of example statuses" do + let(:all_examples) { [double("example")] } + + def run + allow(world).to receive(:all_examples).and_return(all_examples) + allow(config).to receive(:load_spec_files) + + class_spy(ExampleStatusPersister, :load_from => []).as_stubbed_const + + runner = build_runner + runner.run(err, out) + end + + context "when `example_status_persistence_file_path` is configured" do + it 'persists the status of all loaded examples' do + config.example_status_persistence_file_path = "examples.txt" + run + expect(ExampleStatusPersister).to have_received(:persist).with(all_examples, "examples.txt") + end + end + + context "when `example_status_persistence_file_path` is not configured" do + it 'persists the status of all loaded examples' do + config.example_status_persistence_file_path = nil + run + expect(ExampleStatusPersister).not_to have_received(:persist) + end + end + end + context "running files" do include_context "spec files" diff --git a/spec/rspec/core/set_spec.rb b/spec/rspec/core/set_spec.rb new file mode 100644 index 0000000000..5388f02c6f --- /dev/null +++ b/spec/rspec/core/set_spec.rb @@ -0,0 +1,36 @@ +RSpec.describe 'RSpec::Core::Set' do + + let(:set) { RSpec::Core::Set.new([1, 2, 3]) } + + it 'takes an array of values' do + expect(set).to include(1, 2, 3) + end + + it 'can be appended to' do + set << 4 + expect(set).to include 4 + end + + it 'can have more values merged in' do + set.merge([4, 5]).merge([6]) + expect(set).to include(4, 5, 6) + end + + it 'is enumerable' do + expect(set).to be_an Enumerable + expect { |p| set.each(&p) }.to yield_successive_args(1, 2, 3) + end + + it 'supports deletions' do + expect { + set.delete(1) + }.to change { set.include?(1) }.from(true).to(false) + end + + it 'indicates if it is empty' do + set = RSpec::Core::Set.new + expect { + set << 1 + }.to change { set.empty? }.from(true).to(false) + end +end diff --git a/spec/rspec/core/shared_context_spec.rb b/spec/rspec/core/shared_context_spec.rb index 2fb1cc9228..dfd11b7b80 100644 --- a/spec/rspec/core/shared_context_spec.rb +++ b/spec/rspec/core/shared_context_spec.rb @@ -66,6 +66,23 @@ expect(group.new.foo).to eq('foo') end + it "supports let when applied to an individual example via metadata" do + shared = Module.new do + extend RSpec::SharedContext + let(:foo) { "bar" } + end + + RSpec.configuration.include shared, :include_it + + ex = value = nil + RSpec.describe "group" do + ex = example("ex1", :include_it) { value = foo } + end.run + + expect(ex.execution_result).to have_attributes(:status => :passed, :exception => nil) + expect(value).to eq("bar") + end + it 'supports explicit subjects' do shared = Module.new do extend RSpec::SharedContext diff --git a/spec/rspec/core/shared_example_group_spec.rb b/spec/rspec/core/shared_example_group_spec.rb index 65b82b8576..39165199d7 100644 --- a/spec/rspec/core/shared_example_group_spec.rb +++ b/spec/rspec/core/shared_example_group_spec.rb @@ -53,6 +53,7 @@ module Core it "is not exposed to the global namespace when monkey patching is disabled" do RSpec.configuration.expose_dsl_globally = false + expect(RSpec.configuration.expose_dsl_globally?).to eq(false) expect(Kernel).to_not respond_to(shared_method_name) end @@ -133,6 +134,22 @@ module Core expect(non_matching_group).not_to respond_to(:bar) end + describe "when it has a `let` and applies to an individual example via metadata" do + it 'defines the `let` method correctly' do + define_shared_group("name", :include_it) do + let(:foo) { "bar" } + end + + ex = value = nil + RSpec.describe "group" do + ex = example("ex1", :include_it) { value = foo } + end.run + + expect(ex.execution_result).to have_attributes(:status => :passed, :exception => nil) + expect(value).to eq("bar") + end + end + describe "hooks for individual examples that have matching metadata" do before do skip "These specs pass in 2.0 mode on JRuby 1.7.8 but fail on " \ diff --git a/spec/rspec/core/world_spec.rb b/spec/rspec/core/world_spec.rb index a837a13c05..95483bb11d 100644 --- a/spec/rspec/core/world_spec.rb +++ b/spec/rspec/core/world_spec.rb @@ -23,6 +23,54 @@ module RSpec::Core end end + describe "#all_example_groups" do + it "contains all example groups from all levels of nesting" do + RSpec.describe "eg1" do + context "eg2" do + context "eg3" + context "eg4" + end + + context "eg5" + end + + RSpec.describe "eg6" do + example + end + + expect(RSpec.world.all_example_groups.map(&:description)).to match_array(%w[ + eg1 eg2 eg3 eg4 eg5 eg6 + ]) + end + end + + describe "#all_examples" do + it "contains all examples from all levels of nesting" do + RSpec.describe do + example("ex1") + + context "nested" do + example("ex2") + + context "nested" do + example("ex3") + example("ex4") + end + end + + example("ex5") + end + + RSpec.describe do + example("ex6") + end + + expect(RSpec.world.all_examples.map(&:description)).to match_array(%w[ + ex1 ex2 ex3 ex4 ex5 ex6 + ]) + end + end + describe "#preceding_declaration_line (again)" do let(:group) do RSpec.describe("group") do @@ -82,9 +130,62 @@ module RSpec::Core end describe "#announce_filters" do - let(:reporter) { double('reporter').as_null_object } + let(:reporter) { instance_spy(Reporter) } before { allow(world).to receive(:reporter) { reporter } } + context "when --only-failures is passed" do + before { configuration.force(:only_failures => true) } + + context "and all examples are filtered out" do + before do + configuration.filter_run_including :foo => 'bar' + end + + it 'will ignore run_all_when_everything_filtered' do + configuration.run_all_when_everything_filtered = true + expect(world.filtered_examples).to_not receive(:clear) + expect(world.inclusion_filter).to_not receive(:clear) + world.announce_filters + end + end + + context "and `example_status_persistence_file_path` is not configured" do + it 'aborts with a message explaining the config option must be set first' do + configuration.example_status_persistence_file_path = nil + world.announce_filters + expect(reporter).to have_received(:abort_with).with(/example_status_persistence_file_path/, 1) + end + end + + context "and `example_status_persistence_file_path` is configured" do + it 'does not abort' do + configuration.example_status_persistence_file_path = "foo.txt" + world.announce_filters + expect(reporter).not_to have_received(:abort_with) + end + end + end + + context "when --only-failures is not passed" do + before { expect(configuration.only_failures?).not_to eq true } + + context "and `example_status_persistence_file_path` is not configured" do + it 'does not abort' do + configuration.example_status_persistence_file_path = nil + world.announce_filters + expect(reporter).not_to have_received(:abort_with) + end + end + + context "and `example_status_persistence_file_path` is configured" do + it 'does not abort' do + configuration.example_status_persistence_file_path = "foo.txt" + world.announce_filters + expect(reporter).not_to have_received(:abort_with) + end + end + end + context "with no examples" do before { allow(world).to receive(:example_count) { 0 } } diff --git a/spec/rspec/core_spec.rb b/spec/rspec/core_spec.rb index a367e92bc8..a3f51b3fa0 100644 --- a/spec/rspec/core_spec.rb +++ b/spec/rspec/core_spec.rb @@ -1,25 +1,64 @@ -require 'rspec/support/spec/prevent_load_time_warnings' +require 'rspec/support/spec/library_wide_checks' RSpec.describe RSpec do - fake_minitest = File.expand_path('../../support/fake_minitest', __FILE__) - it_behaves_like 'a library that issues no warnings when loaded', 'rspec-core', - # Loading minitest issues warnings, so we put our fake minitest on the load - # path to prevent the real minitest from being loaded. - "$LOAD_PATH.unshift '#{fake_minitest}'", 'require "rspec/core"', 'RSpec::Core::Runner.disable_autorun!' do - - pending_when = { - '1.9.2' => { :description => "issues no warnings when loaded" }, - '1.8.7' => { :description => "issues no warnings when the spec files are loaded" }, - '2.0.0' => { } - } - - if RUBY_VERSION == '1.9.2' || RUBY_VERSION == '1.8.7' - before(:example, pending_when.fetch(RUBY_VERSION)) do + fake_libs = File.expand_path('../../support/fake_libs', __FILE__) + allowed_loaded_features = [ + /optparse\.rb/, # Used by OptionParser. + /rbconfig\.rb/, # loaded by rspec-support for OS detection. + /shellwords\.rb/, # used by ConfigurationOptions and RakeTask. + /stringio/, # Used by BaseFormatter. + %r{/fake_libs/}, # ignore these, obviously + ] + + # JRuby appears to not respect `--disable=gem` so rubygems also gets loaded. + allowed_loaded_features << /rubygems/ if RSpec::Support::Ruby.jruby? + + it_behaves_like 'library wide checks', 'rspec-core', + :preamble_for_lib => [ + # rspec-core loads a number of external libraries. We don't want them loaded + # as part of loading all of rspec-core for these specs, for a few reasons: + # + # * Some external libraries issue warnings, which we can't do anything about. + # Since we are trying to prevent _any_ warnings from loading RSpec, it's + # easiest to avoid loading those libraries entirely. + # * Some external libraries load many stdlibs. Here we allow a known set of + # directly loaded stdlibs, and we're not directly concerned with transitive + # dependencies. + # * We're really only concerned with these issues w.r.t. rspec-mocks and + # rspec-expectations from within their spec suites. Here we care only about + # rspec-core, so avoiding loading them helps keep the spec suites independent. + # * These are some of the slowest specs we have, and cutting out the loading + # of external libraries cuts down on how long these specs take. + # + # To facilitate the avoidance of loading certain libraries, we have a bunch + # of files in `support/fake_libs` that substitute for the real things when + # we put that directory on the load path. Here's the list: + # + # * coderay -- loaded by the HTML formatter if availble for syntax highlighting. + # * drb -- loaded when `--drb` is used. Loads other stdlibs (socket, thread, fcntl). + # * erb -- loaded by `ConfigurationOptions` so `.rspec` can use ERB. Loads other stdlibs (strscan, cgi/util). + # * flexmock -- loaded by our Flexmock mocking adapter. + # * json -- loaded by the JSON formatter, loads other stdlibs (ostruct, enc/utf_16le.bundle, etc). + # * minitest -- loaded by our Minitest assertions adapter. + # * mocha -- loaded by our Mocha mocking adapter. + # * rake -- loaded by our Rake task. Loads other stdlibs (fileutils, ostruct, thread, monitor, etc). + # * rr -- loaded by our RR mocking adapter. + # * rspec-mocks -- loaded by our RSpec mocking adapter. + # * rspec-expectations -- loaded by the generated `spec_helper` (defined in project_init). + # * test-unit -- loaded by our T::U assertions adapter. + # + "$LOAD_PATH.unshift '#{fake_libs}'", + # Many files assume this has already been loaded and will have errors if it has not. + 'require "rspec/core"', + # Prevent rspec/autorun from trying to run RSpec. + 'RSpec::Core::Runner.disable_autorun!' + ], :skip_spec_files => %r{/fake_libs/}, :allowed_loaded_feature_regexps => allowed_loaded_features do + if RUBY_VERSION == '1.8.7' + before(:example, :description => /(issues no warnings when the spec files are loaded|stdlibs)/) do pending "Not working on #{RUBY_DESCRIPTION}" end - end - if (RUBY_PLATFORM == 'java' && RUBY_VERSION == '2.0.0') - before(:example, pending_when.fetch(RUBY_VERSION)) do + elsif RUBY_VERSION == '2.0.0' && RSpec::Support::Ruby.jruby? + before(:example) do skip "Not reliably working on #{RUBY_DESCRIPTION}" end end @@ -179,10 +218,19 @@ end end + it 'uses only one thread local variable', :run_last do + # Trigger features that use thread locals... + aggregate_failures { } + RSpec.shared_examples_for("something") { } + + expect(Thread.current.keys.map(&:to_s).grep(/rspec/i).count).to eq(1) + end + describe "::Core.path_to_executable" do - it 'returns the absolute location of the exe/rspec file', :failing_on_appveyor do + it 'returns the absolute location of the exe/rspec file' do expect(File.exist? RSpec::Core.path_to_executable).to be_truthy - expect(File.executable? RSpec::Core.path_to_executable).to be_truthy + expect(File.read(RSpec::Core.path_to_executable)).to include("RSpec::Core::Runner.invoke") + expect(File.executable? RSpec::Core.path_to_executable).to be_truthy unless RSpec::Support::OS.windows? end end @@ -197,6 +245,8 @@ expect(err).to eq("") expect(out.split("\n")).to eq(%w[ RSpec::Mocks RSpec::Expectations ]) expect(status.exitstatus).to eq(0) + + expect(RSpec.const_missing(:Expectations)).to be(RSpec::Expectations) end it 'correctly raises an error when an invalid const is referenced' do @@ -204,5 +254,14 @@ RSpec::NotAConst }.to raise_error(NameError, /RSpec::NotAConst/) end + + it "does not blow up if some gem defines `Kernel#it`", :slow do + code = 'Kernel.module_eval { def it(*); end }; require "rspec/core"' + out, err, status = run_ruby_with_current_load_path(code) + + expect(err).to eq("") + expect(out).to eq("") + expect(status.exitstatus).to eq(0) + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 81bc9b3a65..629f13add2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -22,12 +22,29 @@ def self.new(*args, &block) end Dir['./spec/support/**/*.rb'].map do |file| + # fake libs aren't intended to be loaded except by some specific specs + # that shell out and run a new process. + next if file =~ /fake_libs/ + # Ensure requires are relative to `spec`, which is on the # load path. This helps prevent double requires on 1.8.7. require file.gsub("./spec/support", "support") end -module EnvHelpers +class RaiseOnFailuresReporter < RSpec::Core::NullReporter + def self.example_failed(example) + raise example.exception + end +end + +module CommonHelpers + def describe_successfully(description="", &describe_body) + example_group = RSpec.describe(description, &describe_body) + ran_successfully = example_group.run RaiseOnFailuresReporter + expect(ran_successfully).to eq true + example_group + end + def with_env_vars(vars) original = ENV.to_hash vars.each { |k, v| ENV[k] = v } @@ -49,9 +66,21 @@ def without_env_vars(*vars) ENV.replace(original) end end + + def handle_current_dir_change + RSpec::Core::Metadata.instance_variable_set(:@relative_path_regex, nil) + yield + ensure + RSpec::Core::Metadata.instance_variable_set(:@relative_path_regex, nil) + end end RSpec.configure do |c| + c.example_status_persistence_file_path = "./spec/examples.txt" + c.around(:example, :isolated_directory) do |ex| + handle_current_dir_change(&ex) + end + # structural c.alias_it_behaves_like_to 'it_has_behavior' c.include(RSpecHelpers) @@ -60,7 +89,16 @@ def without_env_vars(*vars) # runtime options c.raise_errors_for_deprecations! c.color = true - c.include EnvHelpers + c.include CommonHelpers + + c.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + c.around(:example, :simulate_shell_allowing_unquoted_ids) do |ex| + with_env_vars('SHELL' => '/usr/local/bin/bash', &ex) + end + c.filter_run_excluding :ruby => lambda {|version| case version.to_s when "!jruby" @@ -71,4 +109,6 @@ def without_env_vars(*vars) !(RUBY_VERSION.to_s =~ /^#{version.to_s}/) end } + + $original_rspec_configuration = c end diff --git a/spec/support/aruba_support.rb b/spec/support/aruba_support.rb index 662ddbb97d..492af9ebb2 100644 --- a/spec/support/aruba_support.rb +++ b/spec/support/aruba_support.rb @@ -13,16 +13,23 @@ module ArubaLoader attr_reader :last_cmd_stdout, :last_cmd_stderr def run_command(cmd) + RSpec.configuration.color = true + temp_stdout = StringIO.new temp_stderr = StringIO.new - RSpec::Core::Metadata.instance_variable_set(:@relative_path_regex, nil) - in_current_dir do - RSpec::Core::Runner.run(cmd.split, temp_stderr, temp_stdout) + # So that `RSpec.warning` will go to temp_stderr. + allow(::Kernel).to receive(:warn) { |msg| temp_stderr.puts(msg) } + cmd_parts = Shellwords.split(cmd) + + handle_current_dir_change do + in_current_dir do + RSpec::Core::Runner.run(cmd_parts, temp_stderr, temp_stdout) + end end ensure RSpec.reset - RSpec::Core::Metadata.instance_variable_set(:@relative_path_regex, nil) + RSpec.configuration.color = true # Ensure it gets cached with a proper value -- if we leave it set to nil, # and the next spec operates in a different dir, it could get set to an diff --git a/spec/support/fake_bisect_runner.rb b/spec/support/fake_bisect_runner.rb new file mode 100644 index 0000000000..45df8edea4 --- /dev/null +++ b/spec/support/fake_bisect_runner.rb @@ -0,0 +1,23 @@ +FakeBisectRunner = Struct.new(:all_ids, :always_failures, :dependent_failures) do + def original_cli_args + [] + end + + def original_results + failures = always_failures | dependent_failures.keys + RSpec::Core::Formatters::BisectFormatter::RunResults.new(all_ids, failures.sort) + end + + def run(ids) + failures = ids & always_failures + dependent_failures.each do |failing_example, depends_upon| + failures << failing_example if ids.include?(depends_upon) + end + + RSpec::Core::Formatters::BisectFormatter::RunResults.new(ids.sort, failures.sort) + end + + def repro_command_from(locations) + "rspec #{locations.sort.join(' ')}" + end +end diff --git a/spec/support/fake_minitest/minitest.rb b/spec/support/fake_libs/coderay.rb similarity index 100% rename from spec/support/fake_minitest/minitest.rb rename to spec/support/fake_libs/coderay.rb diff --git a/spec/support/fake_libs/drb/acl.rb b/spec/support/fake_libs/drb/acl.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/support/fake_libs/drb/drb.rb b/spec/support/fake_libs/drb/drb.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/support/fake_libs/erb.rb b/spec/support/fake_libs/erb.rb new file mode 100644 index 0000000000..4529438d7c --- /dev/null +++ b/spec/support/fake_libs/erb.rb @@ -0,0 +1,4 @@ +module ERB + module Util + end +end diff --git a/spec/support/fake_libs/flexmock/rspec.rb b/spec/support/fake_libs/flexmock/rspec.rb new file mode 100644 index 0000000000..c5b6c9f337 --- /dev/null +++ b/spec/support/fake_libs/flexmock/rspec.rb @@ -0,0 +1,4 @@ +module FlexMock + module MockContainer + end +end diff --git a/spec/support/fake_libs/json.rb b/spec/support/fake_libs/json.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/support/fake_libs/minitest.rb b/spec/support/fake_libs/minitest.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/support/fake_minitest/minitest/minitest_assertions.rb b/spec/support/fake_libs/minitest/assertions.rb similarity index 100% rename from spec/support/fake_minitest/minitest/minitest_assertions.rb rename to spec/support/fake_libs/minitest/assertions.rb diff --git a/spec/support/fake_libs/mocha/api.rb b/spec/support/fake_libs/mocha/api.rb new file mode 100644 index 0000000000..a69f6f70ca --- /dev/null +++ b/spec/support/fake_libs/mocha/api.rb @@ -0,0 +1,4 @@ +module Mocha + module API + end +end diff --git a/spec/support/fake_libs/open3.rb b/spec/support/fake_libs/open3.rb new file mode 100644 index 0000000000..596d503017 --- /dev/null +++ b/spec/support/fake_libs/open3.rb @@ -0,0 +1,9 @@ +unless caller.any? { |line| line.include?("rspec/core/bisect/runner.rb") } + raise "open3 loaded from unexpected file. " \ + "It is allowed to be loaded by the Bisect::Runner " \ + "because that is not loaded in the same process as end-user code, " \ + "and we generally don't want open3 loaded for other things." +end + +module Open3 +end diff --git a/spec/support/fake_libs/rake.rb b/spec/support/fake_libs/rake.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spec/support/fake_libs/rake/tasklib.rb b/spec/support/fake_libs/rake/tasklib.rb new file mode 100644 index 0000000000..d7c9fba492 --- /dev/null +++ b/spec/support/fake_libs/rake/tasklib.rb @@ -0,0 +1,4 @@ +module Rake + class TaskLib + end +end diff --git a/spec/support/fake_libs/rr.rb b/spec/support/fake_libs/rr.rb new file mode 100644 index 0000000000..f9cc74adbc --- /dev/null +++ b/spec/support/fake_libs/rr.rb @@ -0,0 +1,10 @@ +module RR + module Errors + BACKTRACE_IDENTIFIER = /doesn't matter/ + end + + module Extensions + module InstanceMethods + end + end +end diff --git a/spec/support/fake_libs/rspec/expectations.rb b/spec/support/fake_libs/rspec/expectations.rb new file mode 100644 index 0000000000..3a47761793 --- /dev/null +++ b/spec/support/fake_libs/rspec/expectations.rb @@ -0,0 +1,9 @@ +module RSpec + module Expectations + MultipleExpectationsNotMetError = Class.new(Exception) + end + + module Matchers + def self.configuration; RSpec::Core::NullReporter; end + end +end diff --git a/spec/support/fake_libs/rspec/mocks.rb b/spec/support/fake_libs/rspec/mocks.rb new file mode 100644 index 0000000000..ac20109105 --- /dev/null +++ b/spec/support/fake_libs/rspec/mocks.rb @@ -0,0 +1,8 @@ +module RSpec + module Mocks + module ExampleMethods + end + + def self.configuration; RSpec::Core::NullReporter; end + end +end diff --git a/spec/support/fake_minitest/test/unit/assertions.rb b/spec/support/fake_libs/test/unit/assertions.rb similarity index 100% rename from spec/support/fake_minitest/test/unit/assertions.rb rename to spec/support/fake_libs/test/unit/assertions.rb diff --git a/spec/support/formatter_support.rb b/spec/support/formatter_support.rb index 0d146ce0b9..40725e1c36 100644 --- a/spec/support/formatter_support.rb +++ b/spec/support/formatter_support.rb @@ -1,5 +1,5 @@ module FormatterSupport - def run_example_specs_with_formatter(formatter_option) + def run_example_specs_with_formatter(formatter_option, normalize_output=true) options = RSpec::Core::ConfigurationOptions.new(%W[spec/rspec/core/resources/formatter_specs.rb --format #{formatter_option} --order defined]) err, out = StringIO.new, StringIO.new @@ -13,7 +13,8 @@ def run_example_specs_with_formatter(formatter_option) runner.run(err, out) output = out.string - output.gsub!(/\d+(?:\.\d+)?(s| seconds)/, "n.nnnn\\1") + return output unless normalize_output + output = normalize_durations(output) caller_line = RSpec::Core::Metadata.relative_path(caller.first) output.lines.reject do |line| @@ -29,6 +30,13 @@ def run_example_specs_with_formatter(formatter_option) end.join end + def normalize_durations(output) + output.gsub(/(?:\d+ minutes? )?\d+(?:\.\d+)?(s| seconds?)/) do |dur| + suffix = $1 == "s" ? "s" : " seconds" + "n.nnnn#{suffix}" + end + end + if RUBY_VERSION.to_f < 1.9 def expected_summary_output_for_example_specs <<-EOS.gsub(/^\s+\|/, '').chomp @@ -36,7 +44,7 @@ def expected_summary_output_for_example_specs | | 1) pending spec with no implementation is pending | # Not yet implemented - | # ./spec/rspec/core/resources/formatter_specs.rb:4 + | # ./spec/rspec/core/resources/formatter_specs.rb:11 | | 2) pending command with block format with content that would fail is pending | # No reason given @@ -46,17 +54,17 @@ def expected_summary_output_for_example_specs | got: 1 | | (compared using ==) - | # ./spec/rspec/core/resources/formatter_specs.rb:11 + | # ./spec/rspec/core/resources/formatter_specs.rb:18 | # ./spec/support/formatter_support.rb:13:in `run_example_specs_with_formatter' - | # ./spec/support/sandboxing.rb:16 | # ./spec/support/sandboxing.rb:14 - | # ./spec/support/sandboxing.rb:8 + | # ./spec/support/sandboxing.rb:7 | |Failures: | - | 1) pending command with block format with content that would pass fails FIXED + | 1) pending command with block format behaves like shared is marked as pending but passes FIXED | Expected pending 'No reason given' to fail. No Error was raised. - | # ./spec/rspec/core/resources/formatter_specs.rb:16 + | Shared Example Group: "shared" called from ./spec/rspec/core/resources/formatter_specs.rb:22 + | # ./spec/rspec/core/resources/formatter_specs.rb:4 | | 2) failing spec fails | Failure/Error: expect(1).to eq(2) @@ -65,11 +73,10 @@ def expected_summary_output_for_example_specs | got: 1 | | (compared using ==) - | # ./spec/rspec/core/resources/formatter_specs.rb:31 + | # ./spec/rspec/core/resources/formatter_specs.rb:33 | # ./spec/support/formatter_support.rb:13:in `run_example_specs_with_formatter' - | # ./spec/support/sandboxing.rb:16 | # ./spec/support/sandboxing.rb:14 - | # ./spec/support/sandboxing.rb:8 + | # ./spec/support/sandboxing.rb:7 | | 3) a failing spec with odd backtraces fails with a backtrace that has no file | Failure/Error: Unable to find matching line from backtrace @@ -88,10 +95,10 @@ def expected_summary_output_for_example_specs | |Failed examples: | - |rspec ./spec/rspec/core/resources/formatter_specs.rb:16 # pending command with block format with content that would pass fails - |rspec ./spec/rspec/core/resources/formatter_specs.rb:30 # failing spec fails - |rspec ./spec/rspec/core/resources/formatter_specs.rb:36 # a failing spec with odd backtraces fails with a backtrace that has no file - |rspec ./spec/rspec/core/resources/formatter_specs.rb:42 # a failing spec with odd backtraces fails with a backtrace containing an erb file + |rspec ./spec/rspec/core/resources/formatter_specs.rb:4 # pending command with block format behaves like shared is marked as pending but passes + |rspec ./spec/rspec/core/resources/formatter_specs.rb:32 # failing spec fails + |rspec ./spec/rspec/core/resources/formatter_specs.rb:38 # a failing spec with odd backtraces fails with a backtrace that has no file + |rspec ./spec/rspec/core/resources/formatter_specs.rb:44 # a failing spec with odd backtraces fails with a backtrace containing an erb file EOS end else @@ -101,7 +108,7 @@ def expected_summary_output_for_example_specs | | 1) pending spec with no implementation is pending | # Not yet implemented - | # ./spec/rspec/core/resources/formatter_specs.rb:4 + | # ./spec/rspec/core/resources/formatter_specs.rb:11 | | 2) pending command with block format with content that would fail is pending | # No reason given @@ -111,17 +118,17 @@ def expected_summary_output_for_example_specs | got: 1 | | (compared using ==) - | # ./spec/rspec/core/resources/formatter_specs.rb:11:in `block (3 levels) in ' + | # ./spec/rspec/core/resources/formatter_specs.rb:18:in `block (3 levels) in ' | # ./spec/support/formatter_support.rb:13:in `run_example_specs_with_formatter' - | # ./spec/support/sandboxing.rb:16:in `block (4 levels) in ' | # ./spec/support/sandboxing.rb:14:in `block (3 levels) in ' - | # ./spec/support/sandboxing.rb:8:in `block (2 levels) in ' + | # ./spec/support/sandboxing.rb:7:in `block (2 levels) in ' | |Failures: | - | 1) pending command with block format with content that would pass fails FIXED + | 1) pending command with block format behaves like shared is marked as pending but passes FIXED | Expected pending 'No reason given' to fail. No Error was raised. - | # ./spec/rspec/core/resources/formatter_specs.rb:16 + | Shared Example Group: "shared" called from ./spec/rspec/core/resources/formatter_specs.rb:22 + | # ./spec/rspec/core/resources/formatter_specs.rb:4 | | 2) failing spec fails | Failure/Error: expect(1).to eq(2) @@ -130,22 +137,20 @@ def expected_summary_output_for_example_specs | got: 1 | | (compared using ==) - | # ./spec/rspec/core/resources/formatter_specs.rb:31:in `block (2 levels) in ' + | # ./spec/rspec/core/resources/formatter_specs.rb:33:in `block (2 levels) in ' | # ./spec/support/formatter_support.rb:13:in `run_example_specs_with_formatter' - | # ./spec/support/sandboxing.rb:16:in `block (4 levels) in ' | # ./spec/support/sandboxing.rb:14:in `block (3 levels) in ' - | # ./spec/support/sandboxing.rb:8:in `block (2 levels) in ' + | # ./spec/support/sandboxing.rb:7:in `block (2 levels) in ' | | 3) a failing spec with odd backtraces fails with a backtrace that has no file | Failure/Error: ERB.new("<%= raise 'foo' %>").result | RuntimeError: | foo | # (erb):1:in `
' - | # ./spec/rspec/core/resources/formatter_specs.rb:39:in `block (2 levels) in ' + | # ./spec/rspec/core/resources/formatter_specs.rb:41:in `block (2 levels) in ' | # ./spec/support/formatter_support.rb:13:in `run_example_specs_with_formatter' - | # ./spec/support/sandboxing.rb:16:in `block (4 levels) in ' | # ./spec/support/sandboxing.rb:14:in `block (3 levels) in ' - | # ./spec/support/sandboxing.rb:8:in `block (2 levels) in ' + | # ./spec/support/sandboxing.rb:7:in `block (2 levels) in ' | | 4) a failing spec with odd backtraces fails with a backtrace containing an erb file | Failure/Error: Unable to find matching line from backtrace @@ -158,10 +163,10 @@ def expected_summary_output_for_example_specs | |Failed examples: | - |rspec ./spec/rspec/core/resources/formatter_specs.rb:16 # pending command with block format with content that would pass fails - |rspec ./spec/rspec/core/resources/formatter_specs.rb:30 # failing spec fails - |rspec ./spec/rspec/core/resources/formatter_specs.rb:36 # a failing spec with odd backtraces fails with a backtrace that has no file - |rspec ./spec/rspec/core/resources/formatter_specs.rb:42 # a failing spec with odd backtraces fails with a backtrace containing an erb file + |rspec ./spec/rspec/core/resources/formatter_specs.rb:4 # pending command with block format behaves like shared is marked as pending but passes + |rspec ./spec/rspec/core/resources/formatter_specs.rb:32 # failing spec fails + |rspec ./spec/rspec/core/resources/formatter_specs.rb:38 # a failing spec with odd backtraces fails with a backtrace that has no file + |rspec ./spec/rspec/core/resources/formatter_specs.rb:44 # a failing spec with odd backtraces fails with a backtrace containing an erb file EOS end end @@ -180,15 +185,20 @@ def setup_reporter(*streams) @reporter = config.reporter end - def output - @output ||= StringIO.new + def setup_profiler + config.profile_examples = true + reporter.setup_profiler + end + + def formatter_output + @formatter_output ||= StringIO.new end def config @configuration ||= begin config = RSpec::Core::Configuration.new - config.output_stream = output + config.output_stream = formatter_output config end end @@ -205,38 +215,34 @@ def formatter end end - def example - @example ||= - begin - result = instance_double(RSpec::Core::Example::ExecutionResult, - :pending_fixed? => false, - :example_skipped? => false, - :status => :passed - ) - allow(result).to receive(:exception) { exception } - instance_double(RSpec::Core::Example, - :description => "Example", - :full_description => "Example", - :execution_result => result, - :location => "", - :rerun_argument => "", - :metadata => { - :shared_group_inclusion_backtrace => [] - } - ) - end - end + def new_example(metadata = {}) + metadata = metadata.dup + result = RSpec::Core::Example::ExecutionResult.new + result.started_at = ::Time.now + result.record_finished(metadata.delete(:status) { :passed }, ::Time.now) + result.exception = Exception.new if result.status == :failed - def exception - Exception.new + instance_double(RSpec::Core::Example, + :description => "Example", + :full_description => "Example", + :example_group => group, + :execution_result => result, + :location => "", + :location_rerun_argument => "", + :metadata => { + :shared_group_inclusion_backtrace => [] + }.merge(metadata) + ) end def examples(n) - (1..n).map { example } + Array.new(n) { new_example } end def group - class_double "RSpec::Core::ExampleGroup", :description => "Group" + group = class_double "RSpec::Core::ExampleGroup", :description => "Group" + allow(group).to receive(:parent_groups) { [group] } + group end def start_notification(count) @@ -247,12 +253,12 @@ def stop_notification ::RSpec::Core::Notifications::ExamplesNotification.new reporter end - def example_notification(specific_example = example) + def example_notification(specific_example = new_example) ::RSpec::Core::Notifications::ExampleNotification.for specific_example end - def group_notification - ::RSpec::Core::Notifications::GroupNotification.new group + def group_notification group_to_notify = group + ::RSpec::Core::Notifications::GroupNotification.new group_to_notify end def message_notification(message) @@ -276,7 +282,24 @@ def summary_notification(duration, examples, failed, pending, time) end def profile_notification(duration, examples, number) - ::RSpec::Core::Notifications::ProfileNotification.new duration, examples, number + ::RSpec::Core::Notifications::ProfileNotification.new duration, examples, number, reporter.instance_variable_get('@profiler').example_groups end end + +if RSpec::Support::RubyFeatures.module_prepends_supported? + module RSpec::Core + class Reporter + module EnforceRSpecNotificationsListComplete + def notify(event, *args) + return super if caller_locations(1, 1).first.label =~ /publish/ + return super if RSPEC_NOTIFICATIONS.include?(event) + + raise "#{event.inspect} must be added to `RSPEC_NOTIFICATIONS`" + end + end + + prepend EnforceRSpecNotificationsListComplete + end + end +end diff --git a/spec/support/helper_methods.rb b/spec/support/helper_methods.rb index 4b6abe8705..ba7bab4882 100644 --- a/spec/support/helper_methods.rb +++ b/spec/support/helper_methods.rb @@ -11,7 +11,7 @@ def ignoring_warnings result end - def safely + def with_safe_set_to_level_that_triggers_security_errors Thread.new do ignoring_warnings { $SAFE = 3 } yield diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb index b83b9789ca..44a2e4f591 100644 --- a/spec/support/matchers.rb +++ b/spec/support/matchers.rb @@ -107,8 +107,20 @@ def failure_reason(example) failure_message_when_negated { contain_exactly_matcher.failure_message_when_negated } end +RSpec::Matchers.define :first_include do |first_snippet| + chain :then_include, :second_snippet + + match do |string| + string.include?(first_snippet) && + string.include?(second_snippet) && + string.index(first_snippet) < string.index(second_snippet) + end +end + RSpec::Matchers.alias_matcher :a_file_collection, :contain_files RSpec::Matchers.define_negated_matcher :avoid_outputting, :output RSpec::Matchers.define_negated_matcher :exclude, :include +RSpec::Matchers.define_negated_matcher :excluding, :include RSpec::Matchers.define_negated_matcher :avoid_changing, :change +RSpec::Matchers.define_negated_matcher :a_hash_excluding, :include diff --git a/spec/support/sandboxing.rb b/spec/support/sandboxing.rb index dc04bb0c97..90af13e71b 100644 --- a/spec/support/sandboxing.rb +++ b/spec/support/sandboxing.rb @@ -1,5 +1,4 @@ require 'rspec/core/sandbox' -require 'rspec/mocks' # Because testing RSpec with RSpec tries to modify the same global # objects, we sandbox every test. @@ -11,11 +10,9 @@ # something like `pending` config.before(:context) { RSpec.current_example = nil } - RSpec::Mocks.with_temporary_scope do - orig_load_path = $LOAD_PATH.dup - ex.run - $LOAD_PATH.replace(orig_load_path) - end + orig_load_path = $LOAD_PATH.dup + ex.run + $LOAD_PATH.replace(orig_load_path) end end end