test-prof 0.1.0.beta1

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +103 -0
  5. data/assets/flamegraph.demo.html +173 -0
  6. data/assets/flamegraph.template.html +196 -0
  7. data/assets/src/d3-tip.js +352 -0
  8. data/assets/src/d3-tip.min.js +1 -0
  9. data/assets/src/d3.flameGraph.css +92 -0
  10. data/assets/src/d3.flameGraph.js +459 -0
  11. data/assets/src/d3.flameGraph.min.css +1 -0
  12. data/assets/src/d3.flameGraph.min.js +1 -0
  13. data/assets/src/d3.v4.min.js +8 -0
  14. data/guides/any_fixture.md +60 -0
  15. data/guides/before_all.md +98 -0
  16. data/guides/event_prof.md +97 -0
  17. data/guides/factory_doctor.md +64 -0
  18. data/guides/factory_prof.md +85 -0
  19. data/guides/rspec_stamp.md +53 -0
  20. data/guides/rubocop.md +48 -0
  21. data/guides/ruby_prof.md +61 -0
  22. data/guides/stack_prof.md +43 -0
  23. data/lib/test-prof.rb +3 -0
  24. data/lib/test_prof/any_fixture.rb +67 -0
  25. data/lib/test_prof/cops/rspec/aggregate_failures.rb +140 -0
  26. data/lib/test_prof/event_prof/custom_events/factory_create.rb +51 -0
  27. data/lib/test_prof/event_prof/custom_events/sidekiq_inline.rb +48 -0
  28. data/lib/test_prof/event_prof/custom_events/sidekiq_jobs.rb +38 -0
  29. data/lib/test_prof/event_prof/custom_events.rb +5 -0
  30. data/lib/test_prof/event_prof/instrumentations/active_support.rb +16 -0
  31. data/lib/test_prof/event_prof/minitest.rb +3 -0
  32. data/lib/test_prof/event_prof/rspec.rb +94 -0
  33. data/lib/test_prof/event_prof.rb +177 -0
  34. data/lib/test_prof/ext/float_duration.rb +14 -0
  35. data/lib/test_prof/factory_doctor/factory_girl_patch.rb +12 -0
  36. data/lib/test_prof/factory_doctor/minitest.rb +3 -0
  37. data/lib/test_prof/factory_doctor/rspec.rb +96 -0
  38. data/lib/test_prof/factory_doctor.rb +133 -0
  39. data/lib/test_prof/factory_prof/factory_girl_patch.rb +12 -0
  40. data/lib/test_prof/factory_prof/printers/flamegraph.rb +71 -0
  41. data/lib/test_prof/factory_prof/printers/simple.rb +28 -0
  42. data/lib/test_prof/factory_prof.rb +140 -0
  43. data/lib/test_prof/logging.rb +25 -0
  44. data/lib/test_prof/recipes/rspec/any_fixture.rb +21 -0
  45. data/lib/test_prof/recipes/rspec/before_all.rb +23 -0
  46. data/lib/test_prof/rspec_stamp/parser.rb +103 -0
  47. data/lib/test_prof/rspec_stamp/rspec.rb +91 -0
  48. data/lib/test_prof/rspec_stamp.rb +135 -0
  49. data/lib/test_prof/rubocop.rb +3 -0
  50. data/lib/test_prof/ruby_prof/rspec.rb +13 -0
  51. data/lib/test_prof/ruby_prof.rb +194 -0
  52. data/lib/test_prof/stack_prof/rspec.rb +13 -0
  53. data/lib/test_prof/stack_prof.rb +120 -0
  54. data/lib/test_prof/version.rb +5 -0
  55. data/lib/test_prof.rb +108 -0
  56. metadata +227 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ # rubocop: disable Metrics/CyclomaticComplexity
6
+
7
+ module TestProf
8
+ module RSpecStamp
9
+ # Parse examples headers
10
+ module Parser
11
+ # Contains the result of parsing
12
+ class Result
13
+ attr_accessor :fname, :desc
14
+ attr_reader :tags, :htags
15
+
16
+ def add_tag(v)
17
+ @tags ||= []
18
+ @tags << v
19
+ end
20
+
21
+ def add_htag(k, v)
22
+ @htags ||= []
23
+ @htags << [k, v]
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def parse(code)
29
+ sexp = Ripper.sexp(code)
30
+ return unless sexp
31
+
32
+ # sexp has the following format:
33
+ # [:program,
34
+ # [
35
+ # [
36
+ # :command,
37
+ # [:@ident, "it", [1, 0]],
38
+ # [:args_add_block, [ ... ]]
39
+ # ]
40
+ # ]
41
+ # ]
42
+ #
43
+ # or
44
+ #
45
+ # [:program,
46
+ # [
47
+ # [
48
+ # :vcall,
49
+ # [:@ident, "it", [1, 0]]
50
+ # ]
51
+ # ]
52
+ # ]
53
+ res = Result.new
54
+
55
+ fcall = sexp[1][0][1]
56
+ fcall = fcall[1] if fcall.first == :fcall
57
+ res.fname = fcall[1]
58
+
59
+ args_block = sexp[1][0][2]
60
+
61
+ return res if args_block.nil?
62
+
63
+ args_block = args_block[1] if args_block.first == :arg_paren
64
+
65
+ args = args_block[1]
66
+
67
+ if args.first.first == :string_literal
68
+ res.desc = parse_literal(args.shift)
69
+ end
70
+
71
+ parse_arg(res, args.shift) until args.empty?
72
+
73
+ res
74
+ end
75
+
76
+ private
77
+
78
+ def parse_arg(res, arg)
79
+ if arg.first == :symbol_literal
80
+ res.add_tag parse_literal(arg)
81
+ elsif arg.first == :bare_assoc_hash
82
+ parse_hash(res, arg[1])
83
+ end
84
+ end
85
+
86
+ def parse_hash(res, hash_arg)
87
+ hash_arg.each do |(_, label, val)|
88
+ res.add_htag label[1][0..-2].to_sym, parse_literal(val)
89
+ end
90
+ end
91
+
92
+ # Expr of the form:
93
+ # [:string_literal, [:string_content, [:@tstring_content, "is", [1, 4]]]]
94
+ def parse_literal(expr)
95
+ val = expr[1][1][1]
96
+ val = val.to_sym if expr[0] == :symbol_literal ||
97
+ expr[0] == :assoc_new
98
+ val
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestProf
4
+ module RSpecStamp
5
+ class RSpecListener # :nodoc:
6
+ include Logging
7
+
8
+ NOTIFICATIONS = %i[
9
+ example_failed
10
+ ].freeze
11
+
12
+ def initialize
13
+ @failed = 0
14
+ @ignored = 0
15
+ @total = 0
16
+ @examples = Hash.new { |h, k| h[k] = [] }
17
+ end
18
+
19
+ def example_failed(notification)
20
+ return if notification.example.pending?
21
+
22
+ location = notification.example.metadata[:location]
23
+
24
+ file, line = location.split(":")
25
+
26
+ @examples[file] << line.to_i
27
+ end
28
+
29
+ def stamp!
30
+ @examples.each do |file, lines|
31
+ stamp_file(file, lines.uniq)
32
+ end
33
+
34
+ msgs = []
35
+
36
+ msgs <<
37
+ <<~MSG
38
+ RSpec Stamp results
39
+
40
+ Total patches: #{@total}
41
+ Total files: #{@examples.keys.size}
42
+
43
+ Failed patches: #{@failed}
44
+ Ignored files: #{@ignored}
45
+ MSG
46
+
47
+ log :info, msgs.join
48
+ end
49
+
50
+ private
51
+
52
+ def stamp_file(file, lines)
53
+ @total += lines.size
54
+ return if ignored?(file)
55
+
56
+ log :info, "(dry-run) Patching #{file}" if dry_run?
57
+
58
+ code = File.readlines(file)
59
+
60
+ @failed += RSpecStamp.apply_tags(code, lines, RSpecStamp.config.tags)
61
+
62
+ File.write(file, code.join) unless dry_run?
63
+ end
64
+
65
+ def ignored?(file)
66
+ ignored = RSpecStamp.config.ignore_files.find do |pattern|
67
+ file =~ pattern
68
+ end
69
+
70
+ return unless ignored
71
+ log :warn, "Ignore stamping file: #{file}"
72
+ @ignored += 1
73
+ end
74
+
75
+ def dry_run?
76
+ RSpecStamp.config.dry_run?
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # Register EventProf listener
83
+ TestProf.activate('RSTAMP') do
84
+ RSpec.configure do |config|
85
+ listener = TestProf::RSpecStamp::RSpecListener.new
86
+
87
+ config.reporter.register_listener(listener, *TestProf::RSpecStamp::RSpecListener::NOTIFICATIONS)
88
+
89
+ config.after(:suite) { listener.stamp! }
90
+ end
91
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_prof/logging"
4
+ require "test_prof/rspec_stamp/parser"
5
+
6
+ module TestProf
7
+ # Mark RSpec examples with provided tags
8
+ module RSpecStamp
9
+ EXAMPLE_RXP = /(\s*)(\w+\s*(?:.*)\s*)(do|{)/
10
+
11
+ # RSpecStamp configuration
12
+ class Configuration
13
+ attr_accessor :ignore_files, :dry_run, :tags
14
+
15
+ def initialize
16
+ @ignore_files = [%r{spec/support}]
17
+ @dry_run = ENV['RSTAMP_DRY_RUN'] == '1'
18
+ self.tags = ENV['RSTAMP']
19
+ end
20
+
21
+ def dry_run?
22
+ @dry_run == true
23
+ end
24
+
25
+ def tags=(val)
26
+ @tags = if val.is_a?(String)
27
+ parse_tags(val)
28
+ else
29
+ val
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse_tags(str)
36
+ str.split(/\s*,\s*/).each_with_object([]) do |tag, acc|
37
+ k, v = tag.split(":")
38
+ acc << if v.nil?
39
+ k.to_sym
40
+ else
41
+ Hash[k.to_sym, v.to_sym]
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ class << self
48
+ include TestProf::Logging
49
+
50
+ def config
51
+ @config ||= Configuration.new
52
+ end
53
+
54
+ def configure
55
+ yield config
56
+ end
57
+
58
+ # Accepts source code (as array of lines),
59
+ # line numbers (of example to apply tags)
60
+ # and an array of tags.
61
+ def apply_tags(code, lines, tags)
62
+ failed = 0
63
+
64
+ lines.each do |line|
65
+ unless stamp_example(code[line - 1], tags)
66
+ failed += 1
67
+ log :warn, "Failed to stamp: #{code[line - 1]}"
68
+ end
69
+ end
70
+ failed
71
+ end
72
+
73
+ private
74
+
75
+ # rubocop: disable Metrics/CyclomaticComplexity
76
+ # rubocop: disable Metrics/PerceivedComplexity
77
+ def stamp_example(example, tags)
78
+ matches = example.match(EXAMPLE_RXP)
79
+ return false unless matches
80
+
81
+ code = matches[2]
82
+ block = matches[3]
83
+
84
+ parsed = Parser.parse(code)
85
+ return false unless parsed
86
+
87
+ parsed.desc ||= 'works'
88
+
89
+ tags.each do |t|
90
+ if t.is_a?(Hash)
91
+ t.keys.each { |k| parsed.add_htag(k, t[k]) }
92
+ else
93
+ parsed.add_tag(t)
94
+ end
95
+ end
96
+
97
+ need_parens = block == "{"
98
+
99
+ tags_str = parsed.tags.map { |t| t.is_a?(Symbol) ? ":#{t}" : t }.join(", ") unless
100
+ parsed.tags.nil?
101
+
102
+ unless parsed.htags.nil?
103
+ htags_str = parsed.htags.map do |(k, v)|
104
+ vstr = v.is_a?(Symbol) ? ":#{v}" : quote(v)
105
+
106
+ "#{k}: #{vstr}"
107
+ end
108
+ end
109
+
110
+ replacement = "\\1#{parsed.fname}#{need_parens ? '(' : ' '}"\
111
+ "#{[quote(parsed.desc), tags_str, htags_str].compact.join(', ')}"\
112
+ "#{need_parens ? ') ' : ' '}\\3"
113
+
114
+ if config.dry_run?
115
+ log :info, "Patched: #{example.sub(EXAMPLE_RXP, replacement)}"
116
+ else
117
+ example.sub!(EXAMPLE_RXP, replacement)
118
+ end
119
+ true
120
+ end
121
+ # rubocop: enable Metrics/CyclomaticComplexity
122
+ # rubocop: enable Metrics/PerceivedComplexity
123
+
124
+ def quote(str)
125
+ if str.include?("'")
126
+ "\"#{str}\""
127
+ else
128
+ "'#{str}'"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ require "test_prof/rspec_stamp/rspec" if defined?(RSpec)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_prof/cops/rspec/aggregate_failures"
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared example for RSpec to profile specific examples with RubyProf
4
+ RSpec.shared_context "ruby-prof", rprof: true do
5
+ prepend_before do
6
+ @ruby_prof_report = TestProf::RubyProf.profile
7
+ end
8
+
9
+ append_after do |ex|
10
+ next unless @ruby_prof_report
11
+ @ruby_prof_report.dump ex.full_description.parameterize
12
+ end
13
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TestProf
4
+ # RubyProf wrapper.
5
+ #
6
+ # Has 2 modes: global and per-example.
7
+ #
8
+ # Example:
9
+ #
10
+ # # To activate global profiling you can use env variable
11
+ # TEST_RUBY_PROF=1 rspec ...
12
+ #
13
+ # # or in your code
14
+ # TestProf::RubyProf.run
15
+ #
16
+ # To profile a specific examples add :rprof tag to it:
17
+ #
18
+ # it "is doing heavy stuff", :rprof do
19
+ # ...
20
+ # end
21
+ #
22
+ module RubyProf
23
+ # RubyProf configuration
24
+ class Configuration
25
+ # Default list of methods to exclude from profile.
26
+ # Contains a lot of RSpec stuff.
27
+ ELIMINATE_METHODS = [
28
+ /instance_exec/,
29
+ /ExampleGroup>?#run/,
30
+ /Procsy/,
31
+ /AroundHook#execute_with/,
32
+ /HookCollections/,
33
+ /Array#(map|each)/
34
+ ].freeze
35
+
36
+ PRINTERS = {
37
+ 'flat' => 'FlatPrinter',
38
+ 'flat_wln' => 'FlatWithLineNumbers',
39
+ 'graph' => 'GraphPrinter',
40
+ 'graph_html' => 'GraphHtmlPrinter',
41
+ 'dot' => 'DotPrinter',
42
+ '.' => 'DotPrinter',
43
+ 'call_stack' => 'CallStackPrinter',
44
+ 'call_tree' => 'CallTreePrinter'
45
+ }.freeze
46
+
47
+ attr_accessor :printer, :mode, :min_percent,
48
+ :include_threads, :eliminate_methods
49
+
50
+ def initialize
51
+ @printer = :call_stack
52
+ @mode = :wall
53
+ @min_percent = 1
54
+ @include_threads = false
55
+ @eliminate_methods = ELIMINATE_METHODS
56
+ end
57
+
58
+ def include_threads?
59
+ include_threads == true
60
+ end
61
+
62
+ def eliminate_methods?
63
+ !eliminate_methods.nil? &&
64
+ !eliminate_methods.empty?
65
+ end
66
+
67
+ # Returns an array of printer type (ID) and class.
68
+ # Takes ENV variable TEST_RUBY_PROF_PRINTER into account.
69
+ def resolve_printer
70
+ type = ENV['TEST_RUBY_PROF_PRINTER'] || printer
71
+
72
+ return ['custom', type] if type.is_a?(Module)
73
+
74
+ type = type.to_s
75
+
76
+ raise ArgumentError, "Unknown printer: #{type}" unless
77
+ PRINTERS.key?(type)
78
+
79
+ [type, ::RubyProf.const_get(PRINTERS[type])]
80
+ end
81
+ end
82
+
83
+ # Wrapper over RubyProf profiler and printer
84
+ class Report
85
+ include TestProf::Logging
86
+
87
+ def initialize(profiler)
88
+ @profiler = profiler
89
+ end
90
+
91
+ # Stop profiling and generate the report
92
+ # using provided name.
93
+ def dump(name)
94
+ result = @profiler.stop
95
+
96
+ if config.eliminate_methods?
97
+ result.eliminate_methods!(config.eliminate_methods)
98
+ end
99
+
100
+ printer_type, printer_class = config.resolve_printer
101
+ path = build_path name, printer_type
102
+
103
+ File.open(path, 'w') do |f|
104
+ printer_class.new(result).print(f, min_percent: config.min_percent)
105
+ end
106
+
107
+ log :info, "RubyProf report generated: #{path}"
108
+ end
109
+
110
+ private
111
+
112
+ def build_path(name, printer)
113
+ TestProf.artefact_path(
114
+ "ruby-prof-report-#{printer}-#{config.mode}-#{name}.html"
115
+ )
116
+ end
117
+
118
+ def config
119
+ RubyProf.config
120
+ end
121
+ end
122
+
123
+ class << self
124
+ include Logging
125
+
126
+ def config
127
+ @config ||= Configuration.new
128
+ end
129
+
130
+ def configure
131
+ yield config
132
+ end
133
+
134
+ # Run RubyProf and automatically dump
135
+ # a report when the process exits.
136
+ #
137
+ # Use this method to profile the whole run.
138
+ def run
139
+ report = profile
140
+
141
+ return unless report
142
+
143
+ @locked = true
144
+
145
+ log :info, "RubyProf enabled"
146
+
147
+ at_exit { report.dump("total") }
148
+ end
149
+
150
+ def profile
151
+ return if locked?
152
+ return unless init_ruby_prof
153
+
154
+ options = {
155
+ merge_fibers: true
156
+ }
157
+
158
+ options[:include_threads] = [Thread.current] unless
159
+ config.include_threads?
160
+
161
+ profiler = ::RubyProf::Profile.new(options)
162
+ profiler.start
163
+
164
+ Report.new(profiler)
165
+ end
166
+
167
+ private
168
+
169
+ def locked?
170
+ @locked == true
171
+ end
172
+
173
+ def init_ruby_prof
174
+ return @initialized if instance_variable_defined?(:@initialized)
175
+ ENV["RUBY_PROF_MEASURE_MODE"] = config.mode.to_s
176
+ @initialized = TestProf.require(
177
+ 'ruby-prof',
178
+ <<~MSG
179
+ Please, install 'ruby-prof' first:
180
+ # Gemfile
181
+ gem 'ruby-prof', require: false
182
+ MSG
183
+ )
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ require "test_prof/ruby_prof/rspec" if defined?(RSpec)
190
+
191
+ # Hook to run RubyProf globally
192
+ TestProf.activate('TEST_RUBY_PROF') do
193
+ TestProf::RubyProf.run
194
+ end