Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Commit faeeba1

Browse files
committed
Merge pull request #1884 from rspec/example-ids
Example ids
2 parents f96845f + dcbb853 commit faeeba1

20 files changed

+596
-72
lines changed

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Lint/LiteralInInterpolation:
2525

2626
# This should go down over time.
2727
MethodLength:
28-
Max: 155
28+
Max: 40
2929

3030
# Exclude the default spec_helper to make it easier to uncomment out
3131
# default settings (for both users and the Cucumber suite).

Changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ Enhancements:
99
`RSpec::Core::Reporter#publish(event_name, hash_of_attributes)`. (Jon Rowe, #1869)
1010
* Remove dependency on the standard library `Set` and replace with `RSpec::Core::Set`.
1111
(Jon Rowe, #1870)
12+
* Assign a unique id to each example and group so that they can be
13+
uniquely identified, even for shared examples (and similar situations)
14+
where the location isn't unique. (Myron Marston, #1884)
15+
* Use the example id in the rerun command printed for failed examples
16+
when the location is not unique. (Myron Marston, #1884)
1217

1318
Bug Fixes:
1419

lib/rspec/core/configuration.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,13 +1606,19 @@ def absolute_pattern?(pattern)
16061606
end
16071607
end
16081608

1609+
# @private
1610+
ON_SQUARE_BRACKETS = /[\[\]]/
1611+
16091612
def extract_location(path)
16101613
match = /^(.*?)((?:\:\d+)+)$/.match(path)
16111614

16121615
if match
16131616
captures = match.captures
16141617
path, lines = captures[0], captures[1][1..-1].split(":").map { |n| n.to_i }
16151618
filter_manager.add_location path, lines
1619+
else
1620+
path, scoped_ids = path.split(ON_SQUARE_BRACKETS)
1621+
filter_manager.add_ids(path, scoped_ids.split(/\s*,\s*/)) if scoped_ids
16161622
end
16171623

16181624
return [] if path == default_path

lib/rspec/core/example.rb

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,32 @@ def inspect_output
9292
inspect_output
9393
end
9494

95-
# Returns the argument that can be passed to the `rspec` command to rerun this example.
96-
def rerun_argument
97-
loaded_spec_files = RSpec.configuration.loaded_spec_files
95+
# Returns the location-based argument that can be passed to the `rspec` command to rerun this example.
96+
def location_rerun_argument
97+
@location_rerun_argument ||= begin
98+
loaded_spec_files = RSpec.configuration.loaded_spec_files
9899

99-
Metadata.ascending(metadata) do |meta|
100-
return meta[:location] if loaded_spec_files.include?(meta[:absolute_file_path])
100+
Metadata.ascending(metadata) do |meta|
101+
return meta[:location] if loaded_spec_files.include?(meta[:absolute_file_path])
102+
end
101103
end
102104
end
103105

106+
# Returns the location-based argument that can be passed to the `rspec` command to rerun this example.
107+
#
108+
# @deprecated Use {#location_rerun_argument} instead.
109+
# @note If there are multiple examples identified by this location, they will use {#id}
110+
# to rerun instead, but this method will still return the location (that's why it is deprecated!).
111+
def rerun_argument
112+
location_rerun_argument
113+
end
114+
115+
# @return [String] the unique id of this example. Pass
116+
# this at the command line to re-run this exact example.
117+
def id
118+
Metadata.id_from(metadata)
119+
end
120+
104121
# @attr_reader
105122
#
106123
# Returns the first exception raised in the context of running this
@@ -138,7 +155,9 @@ def initialize(example_group_class, description, user_metadata, example_block=ni
138155
@example_block = example_block
139156

140157
@metadata = Metadata::ExampleHash.create(
141-
@example_group_class.metadata, user_metadata, description, example_block
158+
@example_group_class.metadata, user_metadata,
159+
example_group_class.method(:next_runnable_index_for),
160+
description, example_block
142161
)
143162

144163
@example_group_instance = @exception = nil

lib/rspec/core/example_group.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,9 @@ def self.set_it_up(description, *args, &example_group_block)
386386
user_metadata = Metadata.build_hash_from(args)
387387

388388
@metadata = Metadata::ExampleGroupHash.create(
389-
superclass_metadata, user_metadata, description, *args, &example_group_block
389+
superclass_metadata, user_metadata,
390+
superclass.method(:next_runnable_index_for),
391+
description, *args, &example_group_block
390392
)
391393

392394
hooks.register_globals(self, RSpec.configuration.hooks)
@@ -414,6 +416,15 @@ def self.children
414416
@children ||= []
415417
end
416418

419+
# @private
420+
def self.next_runnable_index_for(file)
421+
if self == ExampleGroup
422+
RSpec.world.num_example_groups_defined_in(file)
423+
else
424+
children.count + examples.count
425+
end + 1
426+
end
427+
417428
# @private
418429
def self.descendants
419430
@_descendants ||= [self] + FlatMap.flat_map(children, &:descendants)
@@ -573,6 +584,12 @@ def self.declaration_line_numbers
573584
FlatMap.flat_map(children, &:declaration_line_numbers)
574585
end
575586

587+
# @return [String] the unique id of this example group. Pass
588+
# this at the command line to re-run this exact example group.
589+
def self.id
590+
Metadata.id_from(metadata)
591+
end
592+
576593
# @private
577594
def self.top_level_description
578595
parent_groups.last.description

lib/rspec/core/filter_manager.rb

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ def add_location(file_path, line_numbers)
1616
# locations is a hash of expanded paths to arrays of line
1717
# numbers to match against. e.g.
1818
# { "path/to/file.rb" => [37, 42] }
19-
locations = inclusions.delete(:locations) || Hash.new { |h, k| h[k] = [] }
20-
locations[File.expand_path(file_path)].push(*line_numbers)
21-
inclusions.add(:locations => locations)
19+
add_path_to_arrays_filter(:locations, File.expand_path(file_path), line_numbers)
20+
end
21+
22+
def add_ids(rerun_path, scoped_ids)
23+
# ids is a hash of relative paths to arrays of ids
24+
# to match against. e.g.
25+
# { "./path/to/file.rb" => ["1:1", "2:4"] }
26+
rerun_path = Metadata.relative_path(File.expand_path rerun_path)
27+
add_path_to_arrays_filter(:ids, rerun_path, scoped_ids)
2228
end
2329

2430
def empty?
@@ -32,7 +38,9 @@ def prune(examples)
3238
examples.select { |e| include?(e) }
3339
else
3440
locations = inclusions.fetch(:locations) { Hash.new([]) }
35-
examples.select { |e| priority_include?(e, locations) || (!exclude?(e) && include?(e)) }
41+
ids = inclusions.fetch(:ids) { Hash.new([]) }
42+
43+
examples.select { |e| priority_include?(e, ids, locations) || (!exclude?(e) && include?(e)) }
3644
end
3745
end
3846

@@ -62,6 +70,12 @@ def include_with_low_priority(*args)
6270

6371
private
6472

73+
def add_path_to_arrays_filter(filter_key, path, values)
74+
filter = inclusions.delete(filter_key) || Hash.new { |h, k| h[k] = [] }
75+
filter[path].concat(values)
76+
inclusions.add(filter_key => filter)
77+
end
78+
6579
def exclude?(example)
6680
exclusions.include_example?(example)
6781
end
@@ -82,7 +96,8 @@ def prune_conditionally_filtered_examples(examples)
8296
# and there is a `:slow => true` exclusion filter), but only for specs
8397
# defined in the same file as the location filters. Excluded specs in
8498
# other files should still be excluded.
85-
def priority_include?(example, locations)
99+
def priority_include?(example, ids, locations)
100+
return true if MetadataFilter.filter_applies?(:ids, ids, example.metadata)
86101
return false if locations[example.metadata[:absolute_file_path]].empty?
87102
MetadataFilter.filter_applies?(:locations, locations, example.metadata)
88103
end

lib/rspec/core/metadata.rb

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,21 @@ def self.backtrace_from(block)
105105
[block.source_location.join(':')]
106106
end
107107

108+
# @private
109+
def self.id_from(metadata)
110+
"#{metadata[:rerun_file_path]}[#{metadata[:scoped_id]}]"
111+
end
112+
108113
# @private
109114
# Used internally to populate metadata hashes with computed keys
110115
# managed by RSpec.
111116
class HashPopulator
112117
attr_reader :metadata, :user_metadata, :description_args, :block
113118

114-
def initialize(metadata, user_metadata, description_args, block)
119+
def initialize(metadata, user_metadata, index_provider, description_args, block)
115120
@metadata = metadata
116121
@user_metadata = user_metadata
122+
@index_provider = index_provider
117123
@description_args = description_args
118124
@block = block
119125
end
@@ -151,6 +157,8 @@ def populate_location_attributes
151157
metadata[:line_number] = line_number.to_i
152158
metadata[:location] = "#{relative_file_path}:#{line_number}"
153159
metadata[:absolute_file_path] = File.expand_path(relative_file_path)
160+
metadata[:rerun_file_path] ||= relative_file_path
161+
metadata[:scoped_id] = build_scoped_id_for(relative_file_path)
154162
end
155163

156164
def file_path_and_line_number_from(backtrace)
@@ -173,6 +181,12 @@ def build_description_from(parent_description=nil, my_description=nil)
173181
(parent_description.to_s + separator) << my_description.to_s
174182
end
175183

184+
def build_scoped_id_for(file_path)
185+
index = @index_provider.call(file_path).to_s
186+
parent_scoped_id = metadata.fetch(:scoped_id) { return index }
187+
"#{parent_scoped_id}:#{index}"
188+
end
189+
176190
def ensure_valid_user_keys
177191
RESERVED_KEYS.each do |key|
178192
next unless user_metadata.key?(key)
@@ -196,7 +210,7 @@ def ensure_valid_user_keys
196210

197211
# @private
198212
class ExampleHash < HashPopulator
199-
def self.create(group_metadata, user_metadata, description, block)
213+
def self.create(group_metadata, user_metadata, index, description, block)
200214
example_metadata = group_metadata.dup
201215
group_metadata = Hash.new(&ExampleGroupHash.backwards_compatibility_default_proc do |hash|
202216
hash[:parent_example_group]
@@ -208,7 +222,7 @@ def self.create(group_metadata, user_metadata, description, block)
208222
example_metadata.delete(:parent_example_group)
209223

210224
description_args = description.nil? ? [] : [description]
211-
hash = new(example_metadata, user_metadata, description_args, block)
225+
hash = new(example_metadata, user_metadata, index, description_args, block)
212226
hash.populate
213227
hash.metadata
214228
end
@@ -229,15 +243,15 @@ def full_description
229243

230244
# @private
231245
class ExampleGroupHash < HashPopulator
232-
def self.create(parent_group_metadata, user_metadata, *args, &block)
246+
def self.create(parent_group_metadata, user_metadata, example_group_index, *args, &block)
233247
group_metadata = hash_with_backwards_compatibility_default_proc
234248

235249
if parent_group_metadata
236250
group_metadata.update(parent_group_metadata)
237251
group_metadata[:parent_example_group] = parent_group_metadata
238252
end
239253

240-
hash = new(group_metadata, user_metadata, args, block)
254+
hash = new(group_metadata, user_metadata, example_group_index, args, block)
241255
hash.populate
242256
hash.metadata
243257
end
@@ -308,15 +322,20 @@ def full_description
308322
# @private
309323
RESERVED_KEYS = [
310324
:description,
325+
:description_args,
326+
:described_class,
311327
:example_group,
312328
:parent_example_group,
313329
:execution_result,
314330
:file_path,
315331
:absolute_file_path,
332+
:rerun_file_path,
316333
:full_description,
317334
:line_number,
318335
:location,
319-
:block
336+
:scoped_id,
337+
:block,
338+
:shared_group_inclusion_backtrace
320339
]
321340
end
322341

lib/rspec/core/metadata_filter.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def filter_applies?(key, value, metadata)
1717
silence_metadata_example_group_deprecations do
1818
return filter_applies_to_any_value?(key, value, metadata) if Array === metadata[key] && !(Proc === value)
1919
return location_filter_applies?(value, metadata) if key == :locations
20+
return id_filter_applies?(value, metadata) if key == :ids
2021
return filters_apply?(key, value, metadata) if Hash === value
2122

2223
return false unless metadata.key?(key)
@@ -42,6 +43,14 @@ def filter_applies_to_any_value?(key, value, metadata)
4243
metadata[key].any? { |v| filter_applies?(key, v, key => value) }
4344
end
4445

46+
def id_filter_applies?(rerun_paths_to_scoped_ids, metadata)
47+
scoped_ids = rerun_paths_to_scoped_ids.fetch(metadata[:rerun_file_path]) { return false }
48+
49+
Metadata.ascend(metadata).any? do |meta|
50+
scoped_ids.include?(meta[:scoped_id])
51+
end
52+
end
53+
4554
def location_filter_applies?(locations, metadata)
4655
line_numbers = example_group_declaration_lines(locations, metadata)
4756
line_numbers.empty? || line_number_filter_applies?(line_numbers, metadata)

lib/rspec/core/notifications.rb

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def colorized_totals_line(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
485485
def colorized_rerun_commands(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
486486
"\nFailed examples:\n\n" +
487487
failed_examples.map do |example|
488-
colorizer.wrap("rspec #{example.rerun_argument}", RSpec.configuration.failure_color) + " " +
488+
colorizer.wrap("rspec #{rerun_argument_for(example)}", RSpec.configuration.failure_color) + " " +
489489
colorizer.wrap("# #{example.full_description}", RSpec.configuration.detail_color)
490490
end.join("\n")
491491
end
@@ -515,6 +515,54 @@ def fully_formatted(colorizer=::RSpec::Core::Formatters::ConsoleCodes)
515515

516516
formatted
517517
end
518+
519+
private
520+
521+
def rerun_argument_for(example)
522+
location = example.location_rerun_argument
523+
return location unless duplicate_rerun_locations.include?(location)
524+
conditionally_quote(example.id)
525+
end
526+
527+
def duplicate_rerun_locations
528+
@duplicate_rerun_locations ||= begin
529+
locations = RSpec.world.all_examples.map(&:location_rerun_argument)
530+
531+
Set.new.tap do |s|
532+
locations.group_by { |l| l }.each do |l, ls|
533+
s << l if ls.count > 1
534+
end
535+
end
536+
end
537+
end
538+
539+
# Known shells that require quoting: zsh, csh, tcsh.
540+
#
541+
# Feel free to add other shells to this list that are known to
542+
# allow `rspec ./some_spec.rb[1:1]` syntax without quoting the id.
543+
#
544+
# @private
545+
SHELLS_ALLOWING_UNQUOTED_IDS = %w[ bash ksh fish ]
546+
547+
def conditionally_quote(id)
548+
return id if shell_allows_unquoted_ids?
549+
"'#{id.gsub("'", "\\\\'")}'"
550+
end
551+
552+
def shell_allows_unquoted_ids?
553+
return @shell_allows_unquoted_ids if defined?(@shell_allows_unquoted_ids)
554+
555+
@shell_allows_unquoted_ids = SHELLS_ALLOWING_UNQUOTED_IDS.include?(
556+
# Note: ENV['SHELL'] isn't necessarily the shell the user is currently running.
557+
# According to http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html:
558+
# "This variable shall represent a pathname of the user's preferred command language interpreter."
559+
#
560+
# It's the best we can easily do, though. We err on the side of safety (quoting
561+
# the id when not actually needed) so it's not a big deal if the user is actually
562+
# using a different shell.
563+
ENV['SHELL'].to_s.split('/').last
564+
)
565+
end
518566
end
519567

520568
# The `ProfileNotification` holds information about the results of running a

0 commit comments

Comments
 (0)