test-prof 1.5.2 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 794f7efe13d07689e57f27d14a50a6ac0dc1e01de5b52641a94b6b8fbc6f4f8e
4
- data.tar.gz: 5258154393ae1e875df4ff416beb1155666d350a9cdb0c2e63f37b05d4879470
3
+ metadata.gz: b843db2a99bf53e8c380e748bb73340c573e1de8082f76519530eb0ddf32cb95
4
+ data.tar.gz: 94048d78eaf4857b0a869fad4d551701b0501074cb97ff9ac9ad13a5b69eee03
5
5
  SHA512:
6
- metadata.gz: 1fc69db1c6064900b34fa0da7319ea48926d6d6f187059c7a63786370150e9e2b6c97f409c16afa7919f2e9e32652d6ba242624608c5b0f082e5a6b4721a2de7
7
- data.tar.gz: cd24bd5a9fd594953a054a85cbc85ece2b2e36d0523ce9bd3f75b8840384a54cf1f92cfd6697951f84cb9d47073e7d39d6504325ee48ec7871bed00609a39167
6
+ metadata.gz: 5718228b4b63106230f730ef7ee2e1ed53de8457c266f82859a2824b7f9967ba30d0d26d2ee9e4fb1f6363e04eb6a790560e98d529d7e630d93f7dd9dd2dc990
7
+ data.tar.gz: b6df0f87115a3cfd159b1a132459a7958c204374e9b76a8f0c7282158fe6fe6a11a208a5e60f111d98f89c212d215c8cf6db1087c071257a92a7377f999cae0c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## master (unreleased)
4
4
 
5
+ ## 1.6.0 (2026-03-18)
6
+
7
+ - Add TPS profiler. ([@palkan][])
8
+
9
+ - Add GC profiling to MemoryProf. ([@palkan][])
10
+
11
+ - Upgrade to Sidekiq 8. ([@palkan][])
12
+
13
+ - RSpecStamp now uses Prism if available. ([@kddnewton][])
14
+
15
+ - Upgrade RSpecDissect to show total setup time and let breakdowns. ([@palkan][])
16
+
5
17
  ## 1.5.2 (2026-02-03)
6
18
 
7
19
  - Avoid using `Gem.loaded_specs` methods in RuboCop plugin version check. ([@Rylan12][])
@@ -489,3 +501,4 @@ See [changelog](https://github.com/test-prof/test-prof/blob/v0.8.0/CHANGELOG.md)
489
501
  [@devinburnette]: https://github.com/devinburnette
490
502
  [@elasticspoon]: https://github.com/elasticspoon
491
503
  [@Rylan12]: https://github.com/Rylan12
504
+ [@kddnewton]: https://github.com/kddnewton
@@ -2,7 +2,7 @@
2
2
 
3
3
  TestProf::EventProf::CustomEvents.register("sidekiq.inline") do
4
4
  if TestProf.require(
5
- "sidekiq/testing",
5
+ "sidekiq",
6
6
  <<~MSG
7
7
  Failed to load Sidekiq.
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  TestProf::EventProf::CustomEvents.register("sidekiq.jobs") do
4
4
  if TestProf.require(
5
- "sidekiq/testing",
5
+ "sidekiq",
6
6
  <<~MSG
7
7
  Failed to load Sidekiq.
8
8
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "test_prof/ext/float_duration"
3
4
  require "test_prof/memory_prof/printer/number_to_human"
4
5
  require "test_prof/ext/string_truncate"
5
6
 
@@ -92,5 +93,35 @@ module TestProf
92
93
  number_to_human(item[:memory])
93
94
  end
94
95
  end
96
+
97
+ class GCPrinter < Printer
98
+ using StringTruncate
99
+ using FloatDuration
100
+
101
+ private
102
+
103
+ def mode
104
+ "GC time"
105
+ end
106
+
107
+ def print_total
108
+ "Total GC time: #{(GC.total_time.to_f / 1_000_000_000).duration}\n\n"
109
+ end
110
+
111
+ def print_items(items)
112
+ messages =
113
+ items.map do |item|
114
+ <<~ITEM
115
+ #{item[:name].truncate(30)} (#{item[:location]}) – #{gc_time(item)}
116
+ ITEM
117
+ end
118
+
119
+ messages.join
120
+ end
121
+
122
+ def gc_time(item)
123
+ (item[:memory].to_f / 1_000_000_000).duration
124
+ end
125
+ end
95
126
  end
96
127
  end
@@ -31,10 +31,12 @@ module TestProf
31
31
  end
32
32
 
33
33
  def example_group_started(notification)
34
+ return unless notification.group.top_level?
34
35
  tracker.group_started(notification.group, group(notification))
35
36
  end
36
37
 
37
38
  def example_group_finished(notification)
39
+ return unless notification.group.top_level?
38
40
  tracker.group_finished(notification.group)
39
41
  end
40
42
 
@@ -55,7 +55,7 @@ module TestProf
55
55
  node = list.remove_node(id, track)
56
56
  return unless node
57
57
 
58
- groups << {**node.item, memory: node.hooks_memory}
58
+ groups << {**node.item, memory: node.total_memory}
59
59
  end
60
60
  end
61
61
 
@@ -84,5 +84,15 @@ module TestProf
84
84
  !!@rss_tool
85
85
  end
86
86
  end
87
+
88
+ class GCTracker < Tracker
89
+ def track
90
+ ::GC.total_time
91
+ end
92
+
93
+ def supported?
94
+ ::GC.respond_to?(:total_time)
95
+ end
96
+ end
87
97
  end
88
98
  end
@@ -11,6 +11,7 @@ module TestProf
11
11
  #
12
12
  # TEST_MEM_PROF='rss' rspec ...
13
13
  # TEST_MEM_PROF='alloc' rspec ...
14
+ # TEST_MEM_PROF='gc' rspec ...
14
15
  #
15
16
  # By default MemoryProf shows the top 5 examples and groups (for RSpec) but you can
16
17
  # set how many items to display with `TEST_MEM_PROF_COUNT`:
@@ -25,6 +26,8 @@ module TestProf
25
26
  module MemoryProf
26
27
  # MemoryProf configuration
27
28
  class Configuration
29
+ MODES = %w[alloc rss gc].freeze
30
+
28
31
  attr_reader :mode, :top_count
29
32
 
30
33
  def initialize
@@ -33,7 +36,7 @@ module TestProf
33
36
  end
34
37
 
35
38
  def mode=(value)
36
- @mode = (value == "alloc") ? :alloc : :rss
39
+ @mode = MODES.include?(value) ? value.to_sym : :rss
37
40
  end
38
41
 
39
42
  def top_count=(value)
@@ -45,12 +48,14 @@ module TestProf
45
48
  class << self
46
49
  TRACKERS = {
47
50
  alloc: AllocTracker,
48
- rss: RssTracker
51
+ rss: RssTracker,
52
+ gc: GCTracker
49
53
  }.freeze
50
54
 
51
55
  PRINTERS = {
52
56
  alloc: AllocPrinter,
53
- rss: RssPrinter
57
+ rss: RssPrinter,
58
+ gc: GCPrinter
54
59
  }.freeze
55
60
 
56
61
  def config
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_prof/utils/sized_ordered_set"
4
+ require "test_prof/ext/float_duration"
5
+ require "test_prof/ext/string_truncate"
6
+
7
+ module TestProf # :nodoc: all
8
+ using FloatDuration
9
+ using StringTruncate
10
+
11
+ module RSpecDissect
12
+ class Collector
13
+ attr_reader :results, :name, :top_count
14
+
15
+ def initialize(top_count:)
16
+ @top_count = top_count
17
+ @results = Utils::SizedOrderedSet.new(
18
+ top_count, sort_by: :total_setup
19
+ )
20
+ end
21
+
22
+ def <<(data)
23
+ results << data
24
+ end
25
+
26
+ def print_result_header
27
+ <<~MSG
28
+
29
+ Top #{top_count} slowest suites by setup time:
30
+
31
+ MSG
32
+ end
33
+
34
+ def print_group_result(group)
35
+ "#{group[:desc].truncate} (#{group[:loc]}) – \e[1m#{group[:total_setup].duration}\e[22m " \
36
+ "of #{group[:total].duration} / #{group[:count]} " \
37
+ "(before: #{(group[:total_setup] - group[:total_lazy_let]).duration}, " \
38
+ "before let: #{group[:total_before_let].duration}, " \
39
+ "lazy let: #{group[:total_lazy_let].duration})"
40
+ end
41
+
42
+ def print_results
43
+ msgs = [print_result_header]
44
+
45
+ results.each do |group|
46
+ msgs << print_group_result(group)
47
+ msgs << "\n" if group[:top_lets].any?
48
+ group[:top_lets].each do |let|
49
+ msgs << " ↳ #{let[:name]} – #{let[:duration].duration} (#{let[:size]})\n"
50
+ end
51
+ msgs << "\n"
52
+ end
53
+
54
+ msgs.join
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "test_prof/rspec_dissect/collector"
4
+
3
5
  require "test_prof/ext/float_duration"
4
6
 
5
7
  module TestProf
@@ -15,19 +17,12 @@ module TestProf
15
17
  ].freeze
16
18
 
17
19
  def initialize
18
- @collectors = []
19
-
20
- if RSpecDissect.config.let?
21
- collectors << Collectors::Let.new(top_count: RSpecDissect.config.top_count)
22
- end
23
-
24
- if RSpecDissect.config.before?
25
- collectors << Collectors::Before.new(top_count: RSpecDissect.config.top_count)
26
- end
20
+ @collector = Collector.new(top_count: top_count)
27
21
 
28
22
  @examples_count = 0
29
23
  @examples_time = 0.0
30
24
  @total_examples_time = 0.0
25
+ @total_setup_time = 0.0
31
26
  end
32
27
 
33
28
  def example_finished(notification)
@@ -44,9 +39,11 @@ module TestProf
44
39
  data[:desc] = notification.group.top_level_description
45
40
  data[:loc] = notification.group.metadata[:location]
46
41
 
47
- collectors.each { |c| c.populate!(data) }
48
- collectors.each { |c| c << data }
42
+ RSpecDissect.populate_from_spans!(data)
49
43
 
44
+ collector << data
45
+
46
+ @total_setup_time += data[:total_setup]
50
47
  @total_examples_time += @examples_time
51
48
  @examples_count = 0
52
49
  @examples_time = 0.0
@@ -62,17 +59,12 @@ module TestProf
62
59
  RSpecDissect report
63
60
 
64
61
  Total time: #{@total_examples_time.duration}
62
+ Total setup time: #{@total_setup_time.duration}
65
63
  MSG
66
64
 
67
- collectors.each do |c|
68
- msgs << c.total_time_message
69
- end
70
-
71
65
  msgs << "\n"
72
66
 
73
- collectors.each do |c|
74
- msgs << c.print_results
75
- end
67
+ msgs << collector.print_results
76
68
 
77
69
  log :info, msgs.join
78
70
 
@@ -84,9 +76,9 @@ module TestProf
84
76
 
85
77
  examples = Hash.new { |h, k| h[k] = [] }
86
78
 
87
- all_results = collectors.inject([]) { |acc, c| acc + c.results.to_a }
79
+ results = collector.results.to_a
88
80
 
89
- all_results
81
+ results
90
82
  .map { |obj| obj[:loc] }.each do |location|
91
83
  file, line = location.split(":")
92
84
  examples[file] << line.to_i
@@ -114,7 +106,7 @@ module TestProf
114
106
 
115
107
  private
116
108
 
117
- attr_reader :collectors
109
+ attr_reader :collector
118
110
 
119
111
  def top_count
120
112
  RSpecDissect.config.top_count
@@ -7,6 +7,9 @@ module TestProf
7
7
  # RSpecDissect tracks how much time do you spend in `before` hooks
8
8
  # and memoization helpers (i.e. `let`) in your tests.
9
9
  module RSpecDissect
10
+ class Span < Struct.new(:id, :parent_id, :type, :duration, :meta)
11
+ end
12
+
10
13
  module ExampleInstrumentation # :nodoc:
11
14
  def run_before_example(*)
12
15
  RSpecDissect.track(:before) { super }
@@ -15,12 +18,15 @@ module TestProf
15
18
 
16
19
  module MemoizedInstrumentation # :nodoc:
17
20
  def fetch_or_store(id, *)
21
+ return super if id == :subject
22
+ return @memoized[id] if @memoized[id]
23
+
18
24
  res = nil
19
25
  Thread.current[:_rspec_dissect_let_depth] ||= 0
20
26
  Thread.current[:_rspec_dissect_let_depth] += 1
21
27
  begin
22
28
  res = if Thread.current[:_rspec_dissect_let_depth] == 1
23
- RSpecDissect.track(:let, id) { super }
29
+ RSpecDissect.track(:let, name: id) { super }
24
30
  else
25
31
  super
26
32
  end
@@ -33,45 +39,25 @@ module TestProf
33
39
 
34
40
  # RSpecDisect configuration
35
41
  class Configuration
36
- MODES = %w[all let before].freeze
37
-
38
42
  attr_accessor :top_count, :let_stats_enabled,
39
43
  :let_top_count
40
44
 
41
45
  alias_method :let_stats_enabled?, :let_stats_enabled
42
46
 
43
- attr_reader :mode
44
-
45
47
  def initialize
46
48
  @let_stats_enabled = true
47
49
  @let_top_count = (ENV["RD_PROF_LET_TOP"] || 3).to_i
48
50
  @top_count = (ENV["RD_PROF_TOP"] || 5).to_i
49
51
  @stamp = ENV["RD_PROF_STAMP"]
50
- @mode = (ENV["RD_PROF"] == "1") ? "all" : ENV["RD_PROF"]
51
-
52
- unless MODES.include?(mode)
53
- raise "Unknown RSpecDissect mode: #{mode};" \
54
- "available modes: #{MODES.join(", ")}"
55
- end
56
52
 
57
53
  RSpecStamp.config.tags = @stamp if stamp?
58
54
  end
59
55
 
60
- def let?
61
- mode == "all" || mode == "let"
62
- end
63
-
64
- def before?
65
- mode == "all" || mode == "before"
66
- end
67
-
68
56
  def stamp?
69
57
  !@stamp.nil?
70
58
  end
71
59
  end
72
60
 
73
- METRICS = %w[before let].freeze
74
-
75
61
  class << self
76
62
  include Logging
77
63
 
@@ -89,56 +75,64 @@ module TestProf
89
75
  RSpec::Core::MemoizedHelpers::ThreadsafeMemoized.prepend(MemoizedInstrumentation)
90
76
  RSpec::Core::MemoizedHelpers::NonThreadSafeMemoized.prepend(MemoizedInstrumentation)
91
77
 
92
- @data = {}
93
-
94
- METRICS.each do |type|
95
- @data["total_#{type}"] = 0.0
96
- end
97
-
98
78
  reset!
99
79
 
100
80
  log :info, "RSpecDissect enabled"
101
81
  end
102
82
 
103
- def track(type, meta = nil)
104
- start = TestProf.now
105
- res = yield
106
- delta = (TestProf.now - start)
107
- type = type.to_s
108
- @data[type][:time] += delta
109
- @data[type][:meta] << meta unless meta.nil?
110
- @data["total_#{type}"] += delta
111
- res
83
+ def nextid
84
+ @last_id += 1
85
+ @last_id.to_s
112
86
  end
113
87
 
114
- def reset!
115
- METRICS.each do |type|
116
- @data[type.to_s] = {time: 0.0, meta: []}
117
- end
88
+ def current_span
89
+ Thread.current[:_rspec_dissect_spans_stack].last
118
90
  end
119
91
 
120
- # Whether we are able to track `let` usage
121
- def memoization_available?
122
- defined?(::RSpec::Core::MemoizedHelpers::ThreadsafeMemoized)
92
+ def span_stack
93
+ Thread.current[:_rspec_dissect_spans_stack]
123
94
  end
124
95
 
125
- def time_for(key)
126
- @data[key.to_s][:time]
96
+ def track(type, id: nextid, **meta)
97
+ span = Span.new(id, current_span&.id, type, 0.0, meta)
98
+ span_stack << span
99
+
100
+ begin
101
+ start = TestProf.now
102
+ res = yield
103
+ delta = (TestProf.now - start)
104
+ span.duration = delta
105
+ @spans << span
106
+ res
107
+ ensure
108
+ span_stack.pop
109
+ end
127
110
  end
128
111
 
129
- def meta_for(key)
130
- @data[key.to_s][:meta]
112
+ def populate_from_spans!(data)
113
+ data[:total_setup] = @spans.select { !_1.parent_id }.sum(&:duration)
114
+ data[:total_before_let] = @spans.select { _1.type == :let && _1.parent_id }.sum(&:duration).to_f
115
+ data[:total_lazy_let] = @spans.select { _1.type == :let && !_1.parent_id }.sum(&:duration).to_f
116
+
117
+ data[:top_lets] = @spans.select { _1.type == :let }
118
+ .group_by { _1.meta[:name] }
119
+ .transform_values! do |spans|
120
+ {name: spans.first.meta[:name], duration: spans.sum(&:duration), size: spans.size}
121
+ end
122
+ .values
123
+ .sort_by { -_1[:duration] }
124
+ .take(RSpecDissect.config.let_top_count)
131
125
  end
132
126
 
133
- def total_time_for(key)
134
- @data["total_#{key}"]
127
+ def reset!
128
+ @last_id = 1
129
+ @spans = []
130
+ Thread.current[:_rspec_dissect_spans_stack] = []
135
131
  end
136
132
  end
137
133
  end
138
134
  end
139
135
 
140
- require "test_prof/rspec_dissect/collectors/let"
141
- require "test_prof/rspec_dissect/collectors/before"
142
136
  require "test_prof/rspec_dissect/rspec"
143
137
 
144
138
  TestProf.activate("RD_PROF") do
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module TestProf
6
+ module RSpecStamp
7
+ module Parser
8
+ class Prism
9
+ def parse(code)
10
+ result = ::Prism.parse(code)
11
+ return unless result.success?
12
+
13
+ node = result.value.statements.body.first
14
+ return unless node.is_a?(::Prism::CallNode)
15
+
16
+ res = Result.new
17
+ res.fname =
18
+ if node.receiver
19
+ "#{node.receiver.full_name}.#{node.name}"
20
+ else
21
+ node.name.name
22
+ end
23
+
24
+ args = node.arguments&.arguments
25
+ return res if args.nil?
26
+
27
+ rest =
28
+ case (first = args.first).type
29
+ when :string_node
30
+ res.desc = first.content
31
+ args[1..]
32
+ when :constant_read_node, :constant_path_node
33
+ res.desc_const = first.full_name
34
+ args[1..]
35
+ else
36
+ args
37
+ end
38
+
39
+ rest.each do |arg|
40
+ case arg.type
41
+ when :symbol_node
42
+ res.add_tag(arg.value.to_sym)
43
+ when :keyword_hash_node
44
+ arg.elements.each do |assoc|
45
+ res.add_htag(
46
+ assoc.key.value.to_sym,
47
+ parse_htag_value(assoc.value)
48
+ )
49
+ end
50
+ end
51
+ end
52
+
53
+ res
54
+ end
55
+
56
+ private
57
+
58
+ def parse_htag_value(node)
59
+ case node.type
60
+ when :true_node
61
+ true
62
+ when :false_node
63
+ false
64
+ when :integer_node, :float_node
65
+ node.value
66
+ when :string_node
67
+ node.unescaped
68
+ when :symbol_node
69
+ node.value.to_sym
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module TestProf
6
+ module RSpecStamp
7
+ module Parser
8
+ class Ripper
9
+ def parse(code)
10
+ sexp = ::Ripper.sexp(code)
11
+ return unless sexp
12
+
13
+ # sexp has the following format:
14
+ # [:program,
15
+ # [
16
+ # [
17
+ # :command,
18
+ # [:@ident, "it", [1, 0]],
19
+ # [:args_add_block, [ ... ]]
20
+ # ]
21
+ # ]
22
+ # ]
23
+ #
24
+ # or
25
+ #
26
+ # [:program,
27
+ # [
28
+ # [
29
+ # :vcall,
30
+ # [:@ident, "it", [1, 0]]
31
+ # ]
32
+ # ]
33
+ # ]
34
+ res = Result.new
35
+
36
+ fcall = sexp[1][0][1]
37
+ args_block = sexp[1][0][2]
38
+
39
+ if fcall.first == :fcall
40
+ fcall = fcall[1]
41
+ elsif fcall.first == :var_ref
42
+ res.fname = [parse_const(fcall), sexp[1][0][3][1]].join(".")
43
+ args_block = sexp[1][0][4]
44
+ end
45
+
46
+ res.fname ||= fcall[1]
47
+
48
+ return res if args_block.nil?
49
+
50
+ args_block = args_block[1] if args_block.first == :arg_paren
51
+
52
+ args = args_block[1]
53
+
54
+ if args.first.first == :string_literal
55
+ res.desc = parse_literal(args.shift)
56
+ elsif args.first.first == :var_ref || args.first.first == :const_path_ref
57
+ res.desc_const = parse_const(args.shift)
58
+ end
59
+
60
+ parse_arg(res, args.shift) until args.empty?
61
+
62
+ res
63
+ end
64
+
65
+ private
66
+
67
+ def parse_arg(res, arg)
68
+ if arg.first == :symbol_literal
69
+ res.add_tag parse_literal(arg)
70
+ elsif arg.first == :bare_assoc_hash
71
+ parse_hash(res, arg[1])
72
+ end
73
+ end
74
+
75
+ def parse_hash(res, hash_arg)
76
+ hash_arg.each do |(_, label, val)|
77
+ res.add_htag label[1][0..-2].to_sym, parse_value(val)
78
+ end
79
+ end
80
+
81
+ # Expr of the form:
82
+ # bool - [:var_ref, [:@kw, "true", [1, 24]]]
83
+ # string - [:string_literal, [:string_content, [...]]]
84
+ # int - [:@int, "3", [1, 52]]]]
85
+ def parse_value(expr)
86
+ case expr.first
87
+ when :var_ref
88
+ expr[1][1] == "true"
89
+ when :@int
90
+ expr[1].to_i
91
+ when :@float
92
+ expr[1].to_f
93
+ else
94
+ parse_literal(expr)
95
+ end
96
+ end
97
+
98
+ # Expr of the form:
99
+ # [:string_literal, [:string_content, [:@tstring_content, "is", [1, 4]]]]
100
+ def parse_literal(expr)
101
+ val = expr[1][1][1]
102
+ val = val.to_sym if expr[0] == :symbol_literal ||
103
+ expr[0] == :assoc_new
104
+ val
105
+ end
106
+
107
+ # Expr of the form:
108
+ # [:var_ref, [:@const, "User", [1, 9]]]
109
+ #
110
+ # or
111
+ #
112
+ # [:const_path_ref, [:const_path_ref, [:var_ref,
113
+ # [:@const, "User", [1, 17]]],
114
+ # [:@const, "Guest", [1, 23]]],
115
+ # [:@const, "Collection", [1, 30]]
116
+ def parse_const(expr)
117
+ if expr.first == :var_ref
118
+ expr[1][1]
119
+ elsif expr.first == :@const
120
+ expr[1]
121
+ elsif expr.first == :const_path_ref
122
+ expr[1..].map(&method(:parse_const)).join("::")
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ripper"
4
-
5
3
  module TestProf
6
4
  module RSpecStamp
7
5
  # Parse examples headers
@@ -27,126 +25,16 @@ module TestProf
27
25
  end
28
26
  end
29
27
 
30
- class << self
31
- def parse(code)
32
- sexp = Ripper.sexp(code)
33
- return unless sexp
34
-
35
- # sexp has the following format:
36
- # [:program,
37
- # [
38
- # [
39
- # :command,
40
- # [:@ident, "it", [1, 0]],
41
- # [:args_add_block, [ ... ]]
42
- # ]
43
- # ]
44
- # ]
45
- #
46
- # or
47
- #
48
- # [:program,
49
- # [
50
- # [
51
- # :vcall,
52
- # [:@ident, "it", [1, 0]]
53
- # ]
54
- # ]
55
- # ]
56
- res = Result.new
57
-
58
- fcall = sexp[1][0][1]
59
- args_block = sexp[1][0][2]
60
-
61
- if fcall.first == :fcall
62
- fcall = fcall[1]
63
- elsif fcall.first == :var_ref
64
- res.fname = [parse_const(fcall), sexp[1][0][3][1]].join(".")
65
- args_block = sexp[1][0][4]
66
- end
67
-
68
- res.fname ||= fcall[1]
69
-
70
- return res if args_block.nil?
71
-
72
- args_block = args_block[1] if args_block.first == :arg_paren
73
-
74
- args = args_block[1]
75
-
76
- if args.first.first == :string_literal
77
- res.desc = parse_literal(args.shift)
78
- elsif args.first.first == :var_ref || args.first.first == :const_path_ref
79
- res.desc_const = parse_const(args.shift)
80
- end
81
-
82
- parse_arg(res, args.shift) until args.empty?
83
-
84
- res
28
+ instance =
29
+ begin
30
+ require_relative "parser/prism"
31
+ self::Prism.new
32
+ rescue LoadError
33
+ require_relative "parser/ripper"
34
+ self::Ripper.new
85
35
  end
86
- # rubocop: enable Metrics/CyclomaticComplexity
87
- # rubocop: enable Metrics/PerceivedComplexity
88
36
 
89
- private
90
-
91
- def parse_arg(res, arg)
92
- if arg.first == :symbol_literal
93
- res.add_tag parse_literal(arg)
94
- elsif arg.first == :bare_assoc_hash
95
- parse_hash(res, arg[1])
96
- end
97
- end
98
-
99
- def parse_hash(res, hash_arg)
100
- hash_arg.each do |(_, label, val)|
101
- res.add_htag label[1][0..-2].to_sym, parse_value(val)
102
- end
103
- end
104
-
105
- # Expr of the form:
106
- # bool - [:var_ref, [:@kw, "true", [1, 24]]]
107
- # string - [:string_literal, [:string_content, [...]]]
108
- # int - [:@int, "3", [1, 52]]]]
109
- def parse_value(expr)
110
- case expr.first
111
- when :var_ref
112
- expr[1][1] == "true"
113
- when :@int
114
- expr[1].to_i
115
- when :@float
116
- expr[1].to_f
117
- else
118
- parse_literal(expr)
119
- end
120
- end
121
-
122
- # Expr of the form:
123
- # [:string_literal, [:string_content, [:@tstring_content, "is", [1, 4]]]]
124
- def parse_literal(expr)
125
- val = expr[1][1][1]
126
- val = val.to_sym if expr[0] == :symbol_literal ||
127
- expr[0] == :assoc_new
128
- val
129
- end
130
-
131
- # Expr of the form:
132
- # [:var_ref, [:@const, "User", [1, 9]]]
133
- #
134
- # or
135
- #
136
- # [:const_path_ref, [:const_path_ref, [:var_ref,
137
- # [:@const, "User", [1, 17]]],
138
- # [:@const, "Guest", [1, 23]]],
139
- # [:@const, "Collection", [1, 30]]
140
- def parse_const(expr)
141
- if expr.first == :var_ref
142
- expr[1][1]
143
- elsif expr.first == :@const
144
- expr[1]
145
- elsif expr.first == :const_path_ref
146
- expr[1..].map(&method(:parse_const)).join("::")
147
- end
148
- end
149
- end
37
+ define_singleton_method(:parse) { |code| instance.parse(code) }
150
38
  end
151
39
  end
152
40
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "test_prof/core"
3
4
  require "test_prof/logging"
4
5
  require "test_prof/rspec_stamp/parser"
5
6
 
@@ -1,18 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "test_prof/utils/sized_ordered_set"
4
+ require "forwardable"
4
5
 
5
6
  module TestProf
6
7
  module TPSProf
7
8
  class Profiler
8
- attr_reader :top_count, :groups, :total_count, :total_time, :threshold
9
+ extend Forwardable
9
10
 
10
- def initialize(top_count, threshold: 10)
11
- @threshold = threshold
12
- @top_count = top_count
11
+ attr_reader :top_count, :groups, :total_count, :total_time,
12
+ :config
13
+
14
+ def_delegators :@config, :min_examples_count, :min_group_time, :min_target_tps,
15
+ :mode, :max_examples_count, :max_group_time, :min_tps
16
+
17
+ def initialize(top_count, config)
18
+ @config = config
19
+ # In strict mode, we use the sorted set to keep track of offenders
20
+ # to show in the end
21
+ @top_count = (config.mode == :strict) ? 100 : top_count
13
22
  @total_count = 0
14
23
  @total_time = 0.0
15
- @groups = Utils::SizedOrderedSet.new(top_count, sort_by: :tps)
24
+ @groups = Utils::SizedOrderedSet.new(top_count, sort_by: :penalty)
16
25
  end
17
26
 
18
27
  def group_started(id)
@@ -22,20 +31,48 @@ module TestProf
22
31
  @group_started_at = TestProf.now
23
32
  end
24
33
 
25
- def group_finished(id)
26
- return unless @examples_count >= threshold
34
+ def group_finished(group)
35
+ return unless @examples_count >= min_examples_count
36
+
37
+ total_time = TestProf.now - @group_started_at
38
+ shared_setup_time = total_time - @examples_time
39
+
40
+ return unless total_time >= min_group_time
27
41
 
28
- # Context-time
29
- group_time = (TestProf.now - @group_started_at) - @examples_time
30
- run_time = @examples_time + group_time
42
+ tps = (@examples_count / total_time).round(2)
31
43
 
32
- groups << {
33
- id: id,
34
- run_time: run_time,
35
- group_time: group_time,
44
+ return unless tps < min_target_tps
45
+
46
+ # How much time did we waste compared to the target TPS
47
+ penalty = @examples_count * ((1.0 / tps) - (1.0 / min_target_tps))
48
+
49
+ item = {
50
+ id: group,
51
+ total_time: total_time,
52
+ shared_setup_time: shared_setup_time,
36
53
  count: @examples_count,
37
- tps: -(@examples_count / run_time).round(2)
54
+ tps: tps,
55
+ penalty: penalty
38
56
  }
57
+
58
+ if mode == :strict
59
+ location = group.metadata[:location]
60
+
61
+ if TPSProf.handle_group_strictly(
62
+ GroupInfo.new(
63
+ group: group,
64
+ location: location,
65
+ examples_count: @examples_count,
66
+ total_time: total_time,
67
+ tps: tps,
68
+ penalty: penalty
69
+ )
70
+ )
71
+ groups << item
72
+ end
73
+ else
74
+ groups << item
75
+ end
39
76
  end
40
77
 
41
78
  def example_started(id)
@@ -23,25 +23,45 @@ module TestProf
23
23
  <<~MSG
24
24
  Total TPS (tests per second): #{total_tps}
25
25
 
26
- Top #{profiler.top_count} slowest suites by TPS (tests per second) (min examples per group: #{profiler.threshold}):
27
-
28
26
  MSG
29
27
 
28
+ if profiler.mode == :strict
29
+ return if groups.empty?
30
+
31
+ msgs << if groups.size < profiler.top_count
32
+ <<~MSG
33
+ Suites violating TPS limits:
34
+
35
+ MSG
36
+ else
37
+ <<~MSG
38
+ Top #{profiler.top_count} suites violating TPS limits:
39
+
40
+ MSG
41
+ end
42
+ else
43
+ msgs <<
44
+ <<~MSG
45
+ Top #{profiler.top_count} slowest suites by TPS (tests per second):
46
+
47
+ MSG
48
+ end
49
+
30
50
  groups.each do |group|
31
51
  description = group[:id].top_level_description
32
52
  location = group[:id].metadata[:location]
33
- time = group[:run_time]
34
- group_time = group[:group_time]
53
+ time = group[:total_time]
54
+ setup_time = group[:shared_setup_time]
35
55
  count = group[:count]
36
- tps = -group[:tps]
56
+ tps = group[:tps]
37
57
 
38
58
  msgs <<
39
59
  <<~GROUP
40
- #{description.truncate} (#{location}) – #{tps} TPS (#{time.duration} / #{count}), group time: #{group_time.duration}
60
+ #{description.truncate} (#{location}) – #{tps} TPS (#{time.duration} / #{count}, shared setup time: #{setup_time.duration})
41
61
  GROUP
42
62
  end
43
63
 
44
- log :info, msgs.join
64
+ log((profiler.mode == :strict) ? :error : :info, msgs.join)
45
65
  end
46
66
  end
47
67
  end
@@ -15,27 +15,50 @@ module TestProf
15
15
  attr_reader :reporter, :profiler
16
16
 
17
17
  def initialize
18
- @profiler = Profiler.new(TPSProf.config.top_count, threshold: TPSProf.config.threshold)
18
+ @profiler = Profiler.new(TPSProf.config.top_count, TPSProf.config)
19
19
  @reporter = TPSProf.config.reporter
20
20
 
21
- log :info, "TPSProf enabled (top-#{TPSProf.config.top_count})"
21
+ conf = TPSProf.config
22
+ if conf.mode == :strict
23
+ config_parts = []
24
+
25
+ if conf.custom_strict_handler
26
+ config_parts << "custom handler"
27
+ else
28
+ config_parts << "max examples: #{conf.max_examples_count}" if conf.max_examples_count
29
+ config_parts << "max group time: #{conf.max_group_time}" if conf.max_group_time
30
+ config_parts << "min tps: #{conf.min_tps}" if conf.min_tps
31
+ end
32
+
33
+ log :info, "TPSProf strict enabled (#{config_parts.join(", ")})"
34
+ else
35
+ log :info, "TPSProf enabled (top-#{TPSProf.config.top_count})"
36
+ end
22
37
  end
23
38
 
24
39
  def example_group_started(notification)
25
40
  return unless notification.group.top_level?
41
+ return if notification.group.metadata[:tps_prof] == :ignore
42
+
26
43
  profiler.group_started notification.group
27
44
  end
28
45
 
29
46
  def example_group_finished(notification)
30
47
  return unless notification.group.top_level?
48
+ return if notification.group.metadata[:tps_prof] == :ignore
49
+
31
50
  profiler.group_finished notification.group
32
51
  end
33
52
 
34
53
  def example_started(notification)
54
+ return if notification.example.metadata[:tps_prof] == :ignore
55
+
35
56
  profiler.example_started notification.example
36
57
  end
37
58
 
38
59
  def example_finished(notification)
60
+ return if notification.example.metadata[:tps_prof] == :ignore
61
+
39
62
  profiler.example_finished notification.example
40
63
  end
41
64
 
@@ -8,25 +8,81 @@ module TestProf
8
8
  #
9
9
  # Example:
10
10
  #
11
+ # # Show top-10 groups with the worst TPS
11
12
  # TPS_PROF=10 rspec ...
12
13
  #
14
+ # # Report (as errors) groups with lower TPS
15
+ # TPS_PROF_MIN_TPS=10 TPS_PROF=strict rspec ...
13
16
  module TPSProf
17
+ class Error < StandardError; end
18
+
19
+ class GroupInfo < Struct.new(:group, :location, :examples_count, :total_time, :tps, :penalty, keyword_init: true)
20
+ end
21
+
14
22
  class Configuration
15
- attr_accessor :top_count, :threshold, :reporter
23
+ attr_accessor :top_count, :reporter, :mode
24
+
25
+ # Thresholds
26
+ attr_accessor(
27
+ :min_examples_count, # Ignore groups with fewer examples
28
+ :min_group_time, # Ignore groups with less total time (in seconds)
29
+ :min_target_tps, # Ignore groups with higher TPS
30
+ :max_examples_count, # Report groups with more examples in strict mode
31
+ :max_group_time, # Report groups with more total time in strict mode
32
+ :min_tps # Report groups with lower TPS in strict mode
33
+ )
34
+
35
+ attr_reader :custom_strict_handler
16
36
 
17
37
  def initialize
18
- @top_count = ENV["TPS_PROF"].to_i
19
- @top_count = 10 if @top_count == 1
20
- @threshold = ENV.fetch("TPS_PROF_MIN", 10).to_i
38
+ @mode = (ENV["TPS_PROF_MODE"] || ((ENV["TPS_PROF"] == "strict") ? :strict : :profile)).to_sym
39
+ @top_count = ENV["TPS_PROF_COUNT"]&.to_i || ((ENV["TPS_PROF"].to_i > 1) ? ENV["TPS_PROF"].to_i : nil) || 10
40
+
41
+ @min_examples_count = ENV.fetch("TPS_PROF_MIN_EXAMPLES", 10).to_i
42
+ @min_group_time = ENV.fetch("TPS_PROF_MIN_TIME", 5).to_i
43
+ @min_target_tps = ENV.fetch("TPS_PROF_TARGET_TPS", 30).to_i
44
+
45
+ @max_examples_count = ENV["TPS_PROF_MAX_EXAMPLES"]&.to_i
46
+ @max_group_time = ENV["TPS_PROF_MAX_TIME"]&.to_i
47
+ @min_tps = ENV["TPS_PROF_MIN_TPS"]&.to_i
48
+
21
49
  @reporter = resolve_reporter(ENV["TPS_PROF_FORMAT"])
22
50
  end
23
51
 
52
+ def strict_handler
53
+ @strict_handler ||= method(:default_strict_handler)
54
+ end
55
+
56
+ def strict_handler=(val)
57
+ @strict_handler = val
58
+ @custom_strict_handler = true
59
+ end
60
+
24
61
  private
25
62
 
26
63
  def resolve_reporter(format)
27
64
  # TODO: support other formats
28
65
  TPSProf::Reporter::Text.new
29
66
  end
67
+
68
+ def default_strict_handler(group_info)
69
+ error_msg = nil
70
+ location = group_info.location
71
+
72
+ if max_examples_count && group_info.examples_count > max_examples_count
73
+ error_msg ||= "Group #{location} has too many examples: #{group_info.examples_count} > #{max_examples_count}"
74
+ end
75
+
76
+ if max_group_time && group_info.total_time > max_group_time
77
+ error_msg ||= "Group #{location} has too long total time: #{group_info.total_time} > #{max_group_time}"
78
+ end
79
+
80
+ if min_tps && group_info.tps < min_tps
81
+ error_msg ||= "Group #{location} has too low TPS: #{group_info.tps} < #{min_tps}"
82
+ end
83
+
84
+ raise error_msg if error_msg
85
+ end
30
86
  end
31
87
 
32
88
  class << self
@@ -37,6 +93,15 @@ module TestProf
37
93
  def configure
38
94
  yield config
39
95
  end
96
+
97
+ def handle_group_strictly(group_info)
98
+ reporter = ::RSpec.configuration.reporter
99
+ config.strict_handler.call(group_info)
100
+ false
101
+ rescue => err
102
+ reporter.notify_non_example_exception(Error.new(err.message), "")
103
+ true
104
+ end
40
105
  end
41
106
  end
42
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TestProf
4
- VERSION = "1.5.2"
4
+ VERSION = "1.6.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: test-prof
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-03 00:00:00.000000000 Z
11
+ date: 2026-03-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -201,12 +201,12 @@ files:
201
201
  - lib/test_prof/recipes/rspec/let_it_be.rb
202
202
  - lib/test_prof/recipes/rspec/sample.rb
203
203
  - lib/test_prof/rspec_dissect.rb
204
- - lib/test_prof/rspec_dissect/collectors/base.rb
205
- - lib/test_prof/rspec_dissect/collectors/before.rb
206
- - lib/test_prof/rspec_dissect/collectors/let.rb
204
+ - lib/test_prof/rspec_dissect/collector.rb
207
205
  - lib/test_prof/rspec_dissect/rspec.rb
208
206
  - lib/test_prof/rspec_stamp.rb
209
207
  - lib/test_prof/rspec_stamp/parser.rb
208
+ - lib/test_prof/rspec_stamp/parser/prism.rb
209
+ - lib/test_prof/rspec_stamp/parser/ripper.rb
210
210
  - lib/test_prof/rspec_stamp/rspec.rb
211
211
  - lib/test_prof/rubocop.rb
212
212
  - lib/test_prof/ruby_prof.rb
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_prof/utils/sized_ordered_set"
4
- require "test_prof/ext/float_duration"
5
- require "test_prof/ext/string_truncate"
6
-
7
- module TestProf # :nodoc: all
8
- using FloatDuration
9
- using StringTruncate
10
-
11
- module RSpecDissect
12
- module Collectors
13
- class Base
14
- attr_reader :results, :name, :top_count
15
-
16
- def initialize(name:, top_count:)
17
- @name = name
18
- @top_count = top_count
19
- @results = Utils::SizedOrderedSet.new(
20
- top_count, sort_by: name
21
- )
22
- end
23
-
24
- def populate!(data)
25
- data[name] = RSpecDissect.time_for(name)
26
- end
27
-
28
- def <<(data)
29
- results << data
30
- end
31
-
32
- def total_time
33
- RSpecDissect.total_time_for(name)
34
- end
35
-
36
- def total_time_message
37
- "\nTotal `#{print_name}` time: #{total_time.duration}"
38
- end
39
-
40
- def print_name
41
- name
42
- end
43
-
44
- def print_result_header
45
- <<~MSG
46
-
47
- Top #{top_count} slowest suites (by `#{print_name}` time):
48
-
49
- MSG
50
- end
51
-
52
- def print_group_result(group)
53
- <<~GROUP
54
- #{group[:desc].truncate} (#{group[:loc]}) – #{group[name].duration} of #{group[:total].duration} (#{group[:count]})
55
- GROUP
56
- end
57
-
58
- def print_results
59
- msgs = [print_result_header]
60
-
61
- results.each do |group|
62
- msgs << print_group_result(group)
63
- end
64
-
65
- msgs.join
66
- end
67
- end
68
- end
69
- end
70
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_prof/rspec_dissect/collectors/base"
4
-
5
- module TestProf
6
- module RSpecDissect
7
- module Collectors # :nodoc: all
8
- class Before < Base
9
- def initialize(params)
10
- super(name: :before, **params)
11
- end
12
-
13
- def print_name
14
- "before(:each)"
15
- end
16
- end
17
- end
18
- end
19
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "test_prof/rspec_dissect/collectors/base"
4
-
5
- module TestProf
6
- module RSpecDissect
7
- module Collectors # :nodoc: all
8
- class Let < Base
9
- def initialize(params)
10
- super(name: :let, **params)
11
- end
12
-
13
- def populate!(data)
14
- super
15
- data[:let_calls] = RSpecDissect.meta_for(name)
16
- end
17
-
18
- def print_results
19
- return unless RSpecDissect.memoization_available?
20
- super
21
- end
22
-
23
- def print_group_result(group)
24
- return super unless RSpecDissect.config.let_stats_enabled?
25
- msgs = [super]
26
- group[:let_calls]
27
- .group_by(&:itself)
28
- .map { |id, calls| [id, -calls.size] }
29
- .sort_by(&:last)
30
- .take(RSpecDissect.config.let_top_count)
31
- .each do |(id, size)|
32
- msgs << " ↳ #{id} – #{-size}\n"
33
- end
34
- msgs.join
35
- end
36
- end
37
- end
38
- end
39
- end