test-prof 0.1.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
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