polyrun 2.2.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -8
  3. data/README.md +7 -6
  4. data/docs/SETUP_PROFILE.md +2 -1
  5. data/lib/polyrun/cli/ci_shard_run_command.rb +31 -0
  6. data/lib/polyrun/cli/default_run.rb +10 -3
  7. data/lib/polyrun/cli/help.rb +5 -3
  8. data/lib/polyrun/cli/run_shards_command.rb +17 -0
  9. data/lib/polyrun/cli.rb +5 -2
  10. data/lib/polyrun/coverage/collector.rb +21 -49
  11. data/lib/polyrun/coverage/collector_blob.rb +53 -0
  12. data/lib/polyrun/data/parallel_provisioning.rb +2 -2
  13. data/lib/polyrun/hooks/dsl.rb +6 -6
  14. data/lib/polyrun/{minitest.rb → minitest_support.rb} +4 -4
  15. data/lib/polyrun/partition/paths.rb +95 -48
  16. data/lib/polyrun/partition/suite.rb +10 -6
  17. data/lib/polyrun/quick/config.rb +56 -0
  18. data/lib/polyrun/quick/example_group.rb +49 -4
  19. data/lib/polyrun/quick/example_runner.rb +30 -7
  20. data/lib/polyrun/quick/matchers.rb +6 -3
  21. data/lib/polyrun/quick/matchers_predicates.rb +122 -0
  22. data/lib/polyrun/quick/reporter.rb +40 -6
  23. data/lib/polyrun/quick/runner.rb +30 -16
  24. data/lib/polyrun/reporting/rspec_failure_fragment_formatter.rb +1 -1
  25. data/lib/polyrun/reporting/rspec_junit.rb +2 -2
  26. data/lib/polyrun/{rspec → rspec_support}/active_record_timeout_recovery.rb +1 -1
  27. data/lib/polyrun/{rspec → rspec_support}/example_debug.rb +1 -1
  28. data/lib/polyrun/{rspec → rspec_support}/example_debug_instrumentation.rb +1 -1
  29. data/lib/polyrun/{rspec → rspec_support}/sharded_formatter_compat.rb +1 -1
  30. data/lib/polyrun/{rspec.rb → rspec_support.rb} +12 -12
  31. data/lib/polyrun/sus_support.rb +55 -0
  32. data/lib/polyrun/templates/POLYRUN.md +1 -1
  33. data/lib/polyrun/timing/rspec_example_formatter.rb +2 -2
  34. data/lib/polyrun/version.rb +1 -1
  35. data/lib/polyrun/worker_ping.rb +9 -6
  36. data/sig/polyrun/{minitest.rbs → minitest_support.rbs} +1 -1
  37. data/sig/polyrun/{rspec.rbs → rspec_support.rbs} +1 -1
  38. data/sig/polyrun/sus_support.rbs +7 -0
  39. metadata +14 -9
@@ -2,18 +2,24 @@ module Polyrun
2
2
  module Partition
3
3
  # Shared spec path listing for run-shards, queue init, and plan helpers.
4
4
  module Paths
5
+ SUS_CONFIG_REL = "config/sus.rb"
6
+ SUS_TEST_GLOB = "test/**/*.rb"
7
+
5
8
  module_function
6
9
 
7
10
  def read_lines(path)
8
11
  File.read(File.expand_path(path.to_s, Dir.pwd)).split("\n").map(&:strip).reject(&:empty?)
9
12
  end
10
13
 
11
- # Prefer +spec/+ RSpec files, then +test/+ Minitest, then Polyrun Quick files (same globs as +polyrun quick+).
12
- # Order avoids running the broader Quick glob when RSpec or Minitest files already exist.
14
+ # Prefer +spec/+ RSpec files, then Sus (+config/sus.rb+ + +test/**/*.rb+), then +test/+ Minitest,
15
+ # then Polyrun Quick files (same globs as +polyrun quick+).
13
16
  def detect_auto_suite(cwd = Dir.pwd)
14
17
  base = File.expand_path(cwd)
15
18
  return :rspec if Dir.glob(File.join(base, "spec/**/*_spec.rb")).any?
16
19
 
20
+ sus = sus_test_paths(base)
21
+ return :sus if sus_configured?(base) && sus.any?
22
+
17
23
  return :minitest if Dir.glob(File.join(base, "test/**/*_test.rb")).any?
18
24
 
19
25
  quick = quick_parallel_default_paths(base)
@@ -22,32 +28,25 @@ module Polyrun
22
28
  nil
23
29
  end
24
30
 
25
- # Infer parallel suite from explicit paths (+_spec.rb+ vs +_test.rb+ vs Polyrun quick-style +.rb+).
31
+ # Infer parallel suite from explicit paths.
26
32
  # Recognizes RSpec-style +path:line+ locators (strips a trailing numeric locator before classifying).
27
- # Returns +:rspec+, +:minitest+, +:quick+, +:invalid+ (mixed spec and test), or +nil+ (empty).
33
+ # Returns +:rspec+, +:minitest+, +:sus+, +:quick+, +:invalid+ (mixed families), or +nil+ (empty).
28
34
  def infer_suite_from_paths(paths)
29
- paths = Array(paths).map { |p| suite_classify_path(p) }
30
- return nil if paths.empty?
31
-
32
- specs = paths.count { |p| File.basename(p).end_with?("_spec.rb") }
33
- tests = paths.count { |p| File.basename(p).end_with?("_test.rb") }
34
- return :invalid if specs.positive? && tests.positive?
35
-
36
- return :rspec if specs.positive?
37
- return :minitest if tests.positive?
35
+ families = Array(paths).map { |p| suite_family_for_path(p) }.compact
36
+ return nil if families.empty?
38
37
 
39
- others = paths.size - specs - tests
40
- return :quick if others.positive?
38
+ uniq = families.uniq
39
+ return :invalid if uniq.size > 1
41
40
 
42
- nil
41
+ uniq.first
43
42
  end
44
43
 
45
44
  # When +paths_file+ is set but missing, returns +{ error: "..." }+.
46
45
  # Otherwise returns +{ items:, source: }+ (human-readable source label).
47
46
  #
48
- # +partition.suite+ (optional): +auto+ (default), +rspec+, +minitest+, +quick+ — used when resolving
47
+ # +partition.suite+ (optional): +auto+ (default), +rspec+, +minitest+, +sus+, +quick+ — used when resolving
49
48
  # from globs. Legacy +spec/spec_paths.txt+ is used only for +auto+/+rspec+ (RSpec-oriented); an
50
- # explicit +minitest+/+quick+ suite skips it and globs instead. Bare +polyrun+ suite selection is
49
+ # explicit +minitest+/+sus+/+quick+ suite skips it and globs instead. Bare +polyrun+ suite selection is
51
50
  # {Suite.resolve_default} (explicit suite → paths_file inference → {detect_auto_suite}).
52
51
  def resolve_run_shard_items(paths_file: nil, cwd: Dir.pwd, partition: {})
53
52
  if paths_file
@@ -64,50 +63,88 @@ module Polyrun
64
63
  end
65
64
 
66
65
  def resolve_run_shard_items_glob(cwd:, partition: {})
67
- suite = (partition["suite"] || partition[:suite] || "auto").to_s.downcase
68
- suite = "auto" if suite.empty?
66
+ suite = partition_suite_name(partition)
67
+ catalogs = shard_path_catalogs(File.expand_path(cwd))
68
+ case suite
69
+ when "rspec", "minitest", "sus", "quick"
70
+ resolve_named_suite_items(suite, catalogs)
71
+ when "auto"
72
+ resolve_auto_suite_items(catalogs)
73
+ else
74
+ {error: "unknown partition.suite: #{suite.inspect} (expected auto, rspec, minitest, sus, quick)"}
75
+ end
76
+ end
69
77
 
70
- base = File.expand_path(cwd)
71
- spec = Dir.glob(File.join(base, "spec/**/*_spec.rb")).sort
72
- test = Dir.glob(File.join(base, "test/**/*_test.rb")).sort
73
- quick = quick_parallel_default_paths(base)
78
+ def partition_suite_name(partition)
79
+ suite = (partition["suite"] || partition[:suite] || "auto").to_s.downcase
80
+ suite.empty? ? "auto" : suite
81
+ end
82
+ private_class_method :partition_suite_name
83
+
84
+ def shard_path_catalogs(base)
85
+ {
86
+ base: base,
87
+ spec: Dir.glob(File.join(base, "spec/**/*_spec.rb")).sort,
88
+ test: Dir.glob(File.join(base, "test/**/*_test.rb")).sort,
89
+ sus: sus_test_paths(base),
90
+ quick: quick_parallel_default_paths(base)
91
+ }
92
+ end
93
+ private_class_method :shard_path_catalogs
74
94
 
95
+ def resolve_named_suite_items(suite, catalogs)
75
96
  case suite
76
97
  when "rspec"
77
- return {error: "partition.suite is rspec but no spec/**/*_spec.rb files"} if spec.empty?
98
+ return {error: "partition.suite is rspec but no spec/**/*_spec.rb files"} if catalogs[:spec].empty?
78
99
 
79
- {items: spec, source: "spec/**/*_spec.rb glob"}
100
+ {items: catalogs[:spec], source: "spec/**/*_spec.rb glob"}
80
101
  when "minitest"
81
- return {error: "partition.suite is minitest but no test/**/*_test.rb files"} if test.empty?
102
+ return {error: "partition.suite is minitest but no test/**/*_test.rb files"} if catalogs[:test].empty?
82
103
 
83
- {items: test, source: "test/**/*_test.rb glob"}
84
- when "quick"
85
- return {error: "partition.suite is quick but no Polyrun quick files under spec/ or test/"} if quick.empty?
104
+ {items: catalogs[:test], source: "test/**/*_test.rb glob"}
105
+ when "sus"
106
+ return {error: "partition.suite is sus but no test/**/*.rb files"} if catalogs[:sus].empty?
86
107
 
87
- {items: quick, source: "Polyrun quick glob"}
88
- when "auto"
89
- if spec.any?
90
- {items: spec, source: "spec/**/*_spec.rb glob"}
91
- elsif test.any?
92
- {items: test, source: "test/**/*_test.rb glob"}
93
- elsif quick.any?
94
- {items: quick, source: "Polyrun quick glob"}
95
- else
96
- {
97
- error: "no spec paths (spec/spec_paths.txt, partition.paths_file, or spec/**/*_spec.rb); " \
98
- "no test/**/*_test.rb; no Polyrun quick files"
99
- }
100
- end
108
+ {items: catalogs[:sus], source: "test/**/*.rb glob (sus)"}
109
+ else
110
+ return {error: "partition.suite is quick but no Polyrun quick files under spec/ or test/"} if catalogs[:quick].empty?
111
+
112
+ {items: catalogs[:quick], source: "Polyrun quick glob"}
113
+ end
114
+ end
115
+ private_class_method :resolve_named_suite_items
116
+
117
+ def resolve_auto_suite_items(catalogs)
118
+ if catalogs[:spec].any?
119
+ {items: catalogs[:spec], source: "spec/**/*_spec.rb glob"}
120
+ elsif sus_configured?(catalogs[:base]) && catalogs[:sus].any?
121
+ {items: catalogs[:sus], source: "test/**/*.rb glob (sus)"}
122
+ elsif catalogs[:test].any?
123
+ {items: catalogs[:test], source: "test/**/*_test.rb glob"}
124
+ elsif catalogs[:quick].any?
125
+ {items: catalogs[:quick], source: "Polyrun quick glob"}
101
126
  else
102
- {error: "unknown partition.suite: #{suite.inspect} (expected auto, rspec, minitest, quick)"}
127
+ {
128
+ error: "no spec paths (spec/spec_paths.txt, partition.paths_file, or spec/**/*_spec.rb); " \
129
+ "no Sus test/**/*.rb (with config/sus.rb); no test/**/*_test.rb; no Polyrun quick files"
130
+ }
103
131
  end
104
132
  end
133
+ private_class_method :resolve_auto_suite_items
105
134
 
106
135
  def quick_parallel_default_paths(base)
107
136
  require_relative "../quick/runner"
108
137
  Polyrun::Quick::Runner.parallel_default_paths(base)
109
138
  end
110
139
 
140
+ def sus_configured?(base)
141
+ File.file?(File.join(base, SUS_CONFIG_REL))
142
+ end
143
+
144
+ def sus_test_paths(base)
145
+ Dir.glob(File.join(base, SUS_TEST_GLOB)).sort
146
+ end
147
+
111
148
  # Strip trailing numeric locators (+path:line+ or +path:line:column+), then expand.
112
149
  def suite_classify_path(path)
113
150
  raw = path.to_s.sub(/(?::\d+)+\z/, "")
@@ -115,12 +152,22 @@ module Polyrun
115
152
  end
116
153
  private_class_method :suite_classify_path
117
154
 
155
+ def suite_family_for_path(path)
156
+ abs = suite_classify_path(path)
157
+ base = File.basename(abs)
158
+ return :rspec if base.end_with?("_spec.rb")
159
+ return :minitest if base.end_with?("_test.rb")
160
+ return :quick if abs.include?("polyrun_quick")
161
+ return :sus if abs.match?(%r{(?:^|/)test/})
162
+
163
+ :quick
164
+ end
165
+ private_class_method :suite_family_for_path
166
+
118
167
  def use_legacy_spec_paths?(cwd, partition)
119
168
  return false unless File.file?(File.join(cwd, "spec", "spec_paths.txt"))
120
169
 
121
- suite = (partition["suite"] || partition[:suite] || "auto").to_s.downcase
122
- suite = "auto" if suite.empty?
123
- %w[auto rspec].include?(suite)
170
+ %w[auto rspec].include?(partition_suite_name(partition))
124
171
  end
125
172
  private_class_method :use_legacy_spec_paths?
126
173
  end
@@ -2,15 +2,17 @@ module Polyrun
2
2
  module Partition
3
3
  # Chooses the parallel runner suite for bare +polyrun+ and validates command vs path lists.
4
4
  module Suite
5
+ ALLOWED = %w[auto rspec minitest sus quick].freeze
6
+
5
7
  module_function
6
8
 
7
9
  # Resolve suite for default CLI dispatch.
8
10
  # Order: explicit +partition.suite+ (not auto) → infer from +paths_file+ → +detect_auto_suite+.
9
- # Returns +{ suite: :rspec|:minitest|:quick }+ or +{ error: "..." }+.
11
+ # Returns +{ suite: :rspec|:minitest|:sus|:quick }+ or +{ error: "..." }+.
10
12
  def resolve_default(cwd: Dir.pwd, partition: {})
11
13
  configured = configured_suite(partition)
12
- return {error: "unknown partition.suite: #{configured.inspect} (expected auto, rspec, minitest, quick)"} unless
13
- %w[auto rspec minitest quick].include?(configured)
14
+ return {error: "unknown partition.suite: #{configured.inspect} (expected auto, rspec, minitest, sus, quick)"} unless
15
+ ALLOWED.include?(configured)
14
16
 
15
17
  inferred = infer_from_paths_file(cwd, partition)
16
18
  return inferred if inferred.is_a?(Hash) && inferred[:error]
@@ -32,7 +34,8 @@ module Polyrun
32
34
  return {suite: auto} if auto
33
35
 
34
36
  {
35
- error: "no tests found (spec/**/*_spec.rb, test/**/*_test.rb, or Polyrun quick files). See polyrun help."
37
+ error: "no tests found (spec/**/*_spec.rb, Sus config/sus.rb + test/**/*.rb, " \
38
+ "test/**/*_test.rb, or Polyrun quick files). See polyrun help."
36
39
  }
37
40
  end
38
41
 
@@ -41,6 +44,7 @@ module Polyrun
41
44
  return nil if tokens.empty?
42
45
 
43
46
  return :rspec if tokens.any? { |t| File.basename(t) == "rspec" }
47
+ return :sus if tokens.any? { |t| %w[sus sus-parallel].include?(File.basename(t)) }
44
48
  return :quick if tokens.include?("quick") && tokens.any? { |t| File.basename(t).include?("polyrun") }
45
49
  return :minitest if minitest_command?(tokens)
46
50
 
@@ -54,7 +58,7 @@ module Polyrun
54
58
  if path_suite == :invalid
55
59
  return nil unless cmd_suite
56
60
 
57
- return "mixing _spec.rb and _test.rb paths is not supported with a #{cmd_suite} command " \
61
+ return "mixing path families is not supported with a #{cmd_suite} command " \
58
62
  "(use a suite-agnostic custom command, or split the path list)"
59
63
  end
60
64
 
@@ -81,7 +85,7 @@ module Polyrun
81
85
 
82
86
  items = Paths.read_lines(abs)
83
87
  inferred = Paths.infer_suite_from_paths(items)
84
- return {error: "paths_file mixes _spec.rb and _test.rb (not supported in one run)"} if inferred == :invalid
88
+ return {error: "paths_file mixes path families (not supported in one run)"} if inferred == :invalid
85
89
 
86
90
  inferred
87
91
  end
@@ -0,0 +1,56 @@
1
+ module Polyrun
2
+ module Quick
3
+ # Optional host file +config/polyrun_quick.rb+ evaluated before Quick examples run.
4
+ class Config
5
+ PATH = "config/polyrun_quick.rb"
6
+
7
+ attr_reader :before_suite_hooks, :after_suite_hooks, :load_paths
8
+
9
+ def self.load!(root: Dir.pwd)
10
+ cfg = new
11
+ path = File.join(File.expand_path(root), PATH)
12
+ return cfg unless File.file?(path)
13
+
14
+ cfg.instance_eval(File.read(path), path)
15
+ cfg
16
+ end
17
+
18
+ def initialize
19
+ @before_suite_hooks = []
20
+ @after_suite_hooks = []
21
+ @load_paths = []
22
+ end
23
+
24
+ def before_suite(&block)
25
+ @before_suite_hooks << block if block
26
+ end
27
+
28
+ def after_suite(&block)
29
+ @after_suite_hooks << block if block
30
+ end
31
+
32
+ def add_load_path(path)
33
+ @load_paths << path.to_s
34
+ end
35
+
36
+ def apply_load_paths!(root:)
37
+ base = File.expand_path(root)
38
+ @load_paths.each do |rel|
39
+ abs = File.expand_path(rel, base)
40
+ next unless File.directory?(abs)
41
+ next if $LOAD_PATH.include?(abs)
42
+
43
+ $LOAD_PATH.unshift(abs)
44
+ end
45
+ end
46
+
47
+ def run_before_suite!
48
+ @before_suite_hooks.each(&:call)
49
+ end
50
+
51
+ def run_after_suite!(status)
52
+ @after_suite_hooks.each { |h| h.call(status) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,18 +1,22 @@
1
1
  module Polyrun
2
2
  module Quick
3
- # One +describe+ block (possibly nested). Holds +it+ / +test+ examples and hooks.
3
+ # One +describe+ / +with+ block (possibly nested). Holds examples and hooks.
4
4
  class ExampleGroup
5
- attr_reader :name, :parent, :children, :examples, :before_hooks, :after_hooks, :lets, :let_bang_order
5
+ attr_reader :name, :parent, :children, :examples, :before_hooks, :after_hooks, :around_hooks,
6
+ :lets, :let_bang_order, :described_target
6
7
 
7
- def initialize(name, parent: nil)
8
- @name = name.to_s
8
+ def initialize(name, parent: nil, described_target: nil)
9
+ @name = name_label(name)
10
+ @described_target = described_target.nil? ? infer_described_target(name) : described_target
9
11
  @parent = parent
10
12
  @children = []
11
13
  @examples = []
12
14
  @before_hooks = []
13
15
  @after_hooks = []
16
+ @around_hooks = []
14
17
  @lets = {}
15
18
  @let_bang_order = []
19
+ maybe_default_subject!
16
20
  end
17
21
 
18
22
  def full_name
@@ -28,6 +32,8 @@ module Polyrun
28
32
  child
29
33
  end
30
34
 
35
+ alias_method :with, :describe
36
+
31
37
  def it(description, &block)
32
38
  @examples << [description.to_s, block]
33
39
  end
@@ -42,6 +48,10 @@ module Polyrun
42
48
  @after_hooks << block
43
49
  end
44
50
 
51
+ def around(&block)
52
+ @around_hooks << block
53
+ end
54
+
45
55
  def let(name, &block)
46
56
  @lets[name.to_sym] = block
47
57
  end
@@ -52,6 +62,15 @@ module Polyrun
52
62
  @let_bang_order << sym
53
63
  end
54
64
 
65
+ def subject(&block)
66
+ if block
67
+ let(:subject, &block)
68
+ else
69
+ # Used from examples via ExampleRunner method; group-level call without block is invalid.
70
+ raise ArgumentError, "subject requires a block at describe time"
71
+ end
72
+ end
73
+
55
74
  def each_example_with_ancestors(ancestors = [], &visitor)
56
75
  chain = ancestors + [self]
57
76
  @examples.each do |desc, block|
@@ -61,6 +80,32 @@ module Polyrun
61
80
  child.each_example_with_ancestors(chain, &visitor)
62
81
  end
63
82
  end
83
+
84
+ private
85
+
86
+ def name_label(name)
87
+ return name.to_s unless name.is_a?(Module)
88
+
89
+ name.name || name.to_s
90
+ end
91
+
92
+ def infer_described_target(name)
93
+ name.is_a?(Module) ? name : nil
94
+ end
95
+
96
+ def maybe_default_subject!
97
+ return unless @described_target
98
+ return if @lets.key?(:subject)
99
+
100
+ target = @described_target
101
+ @lets[:subject] = -> {
102
+ if target.respond_to?(:new)
103
+ target.new
104
+ else
105
+ target
106
+ end
107
+ }
108
+ end
64
109
  end
65
110
  end
66
111
  end
@@ -26,17 +26,21 @@ module Polyrun
26
26
  qloc = quick_example_location(block)
27
27
  Polyrun::WorkerPing.ping!(location: qloc)
28
28
  Polyrun::SpecQuality.start_example!(location: qloc) if Polyrun::SpecQuality.started?
29
+ error = nil
30
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
31
  begin
30
32
  run_before_hooks_from_chain(ancestor_chain)
31
- instance_eval(&block)
32
- @reporter.pass(group_name, description)
33
+ invoke_arounds(ancestor_chain.flat_map(&:around_hooks), 0) { instance_eval(&block) }
34
+ @reporter.pass(group_name, description, duration_seconds: elapsed_since(started))
33
35
  rescue AssertionFailed => e
34
- @reporter.fail(group_name, description, e)
36
+ error = e
37
+ @reporter.fail(group_name, description, e, duration_seconds: elapsed_since(started))
35
38
  rescue => e
36
- @reporter.error(group_name, description, e)
39
+ error = e
40
+ @reporter.error(group_name, description, e, duration_seconds: elapsed_since(started))
37
41
  ensure
38
42
  Polyrun::SpecQuality.finish_example!(location: qloc) if Polyrun::SpecQuality.started?
39
- run_after_hooks_from_chain(ancestor_chain)
43
+ run_after_hooks_from_chain(ancestor_chain, error: error)
40
44
  reset_capybara_if_enabled!
41
45
  @_let_cache = {}
42
46
  Polyrun::WorkerPing.ping!(location: qloc)
@@ -45,6 +49,10 @@ module Polyrun
45
49
 
46
50
  private
47
51
 
52
+ def elapsed_since(started)
53
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
54
+ end
55
+
48
56
  def quick_example_location(block)
49
57
  loc = block&.source_location
50
58
  loc ? "#{loc[0]}:#{loc[1]}" : nil
@@ -79,10 +87,25 @@ module Polyrun
79
87
  end
80
88
  end
81
89
 
82
- def run_after_hooks_from_chain(ancestor_chain)
90
+ def run_after_hooks_from_chain(ancestor_chain, error: nil)
83
91
  ancestor_chain.reverse_each do |g|
84
- g.after_hooks.reverse_each { |h| instance_eval(&h) }
92
+ g.after_hooks.reverse_each { |h| instance_exec(error, &h) }
93
+ end
94
+ end
95
+
96
+ def invoke_arounds(arounds, index, &body)
97
+ if index >= arounds.size
98
+ body.call
99
+ return
85
100
  end
101
+
102
+ called = false
103
+ run = lambda {
104
+ called = true
105
+ invoke_arounds(arounds, index + 1, &body)
106
+ }
107
+ instance_exec(run, &arounds[index])
108
+ raise AssertionFailed, "around hook did not run the example" unless called
86
109
  end
87
110
 
88
111
  def extend_capybara_if_enabled!
@@ -1,8 +1,9 @@
1
1
  require_relative "errors"
2
+ require_relative "matchers_predicates"
2
3
 
3
4
  module Polyrun
4
5
  module Quick
5
- # Minimal +expect(x).to …+ chain (RSpec-ish) without RSpec.
6
+ # Minimal +expect(x).to …+ / +expect { }.to …+ chain without external gems.
6
7
  class Expectation
7
8
  def initialize(actual)
8
9
  @actual = actual
@@ -22,8 +23,10 @@ module Polyrun
22
23
  end
23
24
 
24
25
  module Matchers
25
- def expect(actual)
26
- Expectation.new(actual)
26
+ include MatchersPredicates
27
+
28
+ def expect(actual = nil, &block)
29
+ Expectation.new(block_given? ? block : actual)
27
30
  end
28
31
 
29
32
  def eq(expected)
@@ -0,0 +1,122 @@
1
+ module Polyrun
2
+ module Quick
3
+ # Predicate-style matchers (+be ==+, +be_a+, +raise_exception+) for Quick expectations.
4
+ module MatchersPredicates
5
+ def be
6
+ BeMatcher.new
7
+ end
8
+
9
+ def be_a(klass)
10
+ BeAMatcher.new(klass)
11
+ end
12
+
13
+ alias_method :be_kind_of, :be_a
14
+
15
+ def raise_exception(exception_class = StandardError, message: nil)
16
+ RaiseExceptionMatcher.new(exception_class, message: message)
17
+ end
18
+ end
19
+
20
+ class BeMatcher
21
+ def ==(other)
22
+ EqMatcher.new(other)
23
+ end
24
+
25
+ def >(other)
26
+ CompareMatcher.new(:>, other)
27
+ end
28
+
29
+ def >=(other)
30
+ CompareMatcher.new(:>=, other)
31
+ end
32
+
33
+ def <(other)
34
+ CompareMatcher.new(:<, other)
35
+ end
36
+
37
+ def <=(other)
38
+ CompareMatcher.new(:<=, other)
39
+ end
40
+ end
41
+
42
+ class CompareMatcher
43
+ def initialize(op, expected)
44
+ @op = op
45
+ @expected = expected
46
+ end
47
+
48
+ def matches?(actual)
49
+ actual.public_send(@op, @expected)
50
+ end
51
+
52
+ def does_not_match?(actual)
53
+ !matches?(actual)
54
+ end
55
+
56
+ def failure_message(actual)
57
+ "expected #{actual.inspect} #{@op} #{@expected.inspect}"
58
+ end
59
+
60
+ def failure_message_when_negated(actual)
61
+ "expected #{actual.inspect} not #{@op} #{@expected.inspect}"
62
+ end
63
+ end
64
+
65
+ class BeAMatcher
66
+ def initialize(klass)
67
+ @klass = klass
68
+ end
69
+
70
+ def matches?(actual)
71
+ actual.is_a?(@klass)
72
+ end
73
+
74
+ def does_not_match?(actual)
75
+ !matches?(actual)
76
+ end
77
+
78
+ def failure_message(actual)
79
+ "expected #{actual.inspect} to be a #{@klass}"
80
+ end
81
+
82
+ def failure_message_when_negated(actual)
83
+ "expected #{actual.inspect} not to be a #{@klass}"
84
+ end
85
+ end
86
+
87
+ class RaiseExceptionMatcher
88
+ def initialize(exception_class, message: nil)
89
+ @exception_class = exception_class
90
+ @message = message
91
+ end
92
+
93
+ def matches?(actual)
94
+ return false unless actual.respond_to?(:call)
95
+
96
+ begin
97
+ actual.call
98
+ false
99
+ rescue @exception_class => e
100
+ return true if @message.nil?
101
+ return true if @message.is_a?(Regexp) && @message.match?(e.message)
102
+
103
+ e.message == @message.to_s
104
+ rescue
105
+ false
106
+ end
107
+ end
108
+
109
+ def does_not_match?(actual)
110
+ !matches?(actual)
111
+ end
112
+
113
+ def failure_message(actual)
114
+ "expected block to raise #{@exception_class}"
115
+ end
116
+
117
+ def failure_message_when_negated(actual)
118
+ "expected block not to raise #{@exception_class}"
119
+ end
120
+ end
121
+ end
122
+ end