appmap 0.23.0 → 0.25.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +17 -8
  4. data/.travis.yml +6 -0
  5. data/CHANGELOG.md +19 -0
  6. data/README.md +29 -12
  7. data/Rakefile +3 -3
  8. data/appmap.gemspec +3 -1
  9. data/exe/appmap +6 -18
  10. data/lib/appmap.rb +47 -6
  11. data/lib/appmap/algorithm/prune_class_map.rb +2 -0
  12. data/lib/appmap/algorithm/stats.rb +4 -2
  13. data/lib/appmap/class_map.rb +143 -0
  14. data/lib/appmap/command/record.rb +8 -6
  15. data/lib/appmap/command/stats.rb +2 -0
  16. data/lib/appmap/command/upload.rb +4 -2
  17. data/lib/appmap/event.rb +168 -0
  18. data/lib/appmap/hook.rb +151 -0
  19. data/lib/appmap/middleware/remote_recording.rb +14 -20
  20. data/lib/appmap/rails/action_handler.rb +10 -6
  21. data/lib/appmap/rails/sql_handler.rb +10 -8
  22. data/lib/appmap/railtie.rb +31 -18
  23. data/lib/appmap/rspec.rb +238 -261
  24. data/lib/appmap/trace.rb +88 -0
  25. data/lib/appmap/version.rb +1 -1
  26. data/package-lock.json +90 -92
  27. data/spec/abstract_controller4_base_spec.rb +1 -1
  28. data/spec/abstract_controller_base_spec.rb +7 -3
  29. data/spec/config_spec.rb +25 -0
  30. data/spec/fixtures/hook/attr_accessor.rb +5 -0
  31. data/spec/fixtures/hook/class_method.rb +17 -0
  32. data/spec/fixtures/hook/constructor.rb +7 -0
  33. data/spec/fixtures/hook/exception_method.rb +11 -0
  34. data/spec/fixtures/hook/instance_method.rb +23 -0
  35. data/spec/fixtures/rails4_users_app/app/controllers/api/users_controller.rb +3 -3
  36. data/spec/fixtures/rails4_users_app/config/database.yml +2 -1
  37. data/spec/fixtures/rails4_users_app/docker-compose.yml +2 -0
  38. data/spec/fixtures/rails_users_app/.ruby-version +1 -1
  39. data/spec/fixtures/rails_users_app/app/controllers/api/users_controller.rb +2 -2
  40. data/spec/fixtures/rails_users_app/config/database.yml +2 -1
  41. data/spec/fixtures/rails_users_app/create_app +1 -0
  42. data/spec/fixtures/rails_users_app/docker-compose.yml +4 -0
  43. data/spec/fixtures/rails_users_app/spec/models/user_spec.rb +1 -1
  44. data/spec/hook_spec.rb +357 -0
  45. data/spec/rails_spec_helper.rb +25 -16
  46. data/spec/railtie_spec.rb +1 -1
  47. data/spec/record_sql_rails_pg_spec.rb +1 -2
  48. data/spec/remote_recording_spec.rb +117 -0
  49. data/spec/spec_helper.rb +1 -0
  50. data/test/cli_test.rb +7 -36
  51. data/test/fixtures/cli_record_test/appmap.yml +2 -1
  52. data/test/fixtures/cli_record_test/lib/cli_record_test/main.rb +4 -2
  53. data/test/test_helper.rb +0 -42
  54. metadata +46 -62
  55. data/exe/_appmap-record-self +0 -49
  56. data/lib/appmap/command/inspect.rb +0 -14
  57. data/lib/appmap/config.rb +0 -65
  58. data/lib/appmap/config/directory.rb +0 -65
  59. data/lib/appmap/config/file.rb +0 -13
  60. data/lib/appmap/config/named_function.rb +0 -21
  61. data/lib/appmap/config/package_dir.rb +0 -52
  62. data/lib/appmap/config/path.rb +0 -25
  63. data/lib/appmap/feature.rb +0 -262
  64. data/lib/appmap/inspect.rb +0 -91
  65. data/lib/appmap/inspect/inspector.rb +0 -99
  66. data/lib/appmap/inspect/parse_node.rb +0 -170
  67. data/lib/appmap/inspect/parser.rb +0 -15
  68. data/lib/appmap/parser.rb +0 -60
  69. data/lib/appmap/rspec/parse_node.rb +0 -41
  70. data/lib/appmap/rspec/parser.rb +0 -15
  71. data/lib/appmap/trace/event_handler/rack_handler_webrick.rb +0 -65
  72. data/lib/appmap/trace/tracer.rb +0 -356
  73. data/spec/fixtures/rails_users_app/bin/_appmap-record-self +0 -29
  74. data/spec/rack_handler_webrick_spec.rb +0 -59
  75. data/test/config_test.rb +0 -149
  76. data/test/explict_inspect_test.rb +0 -29
  77. data/test/fixtures/active_record_like/active_record.rb +0 -2
  78. data/test/fixtures/active_record_like/active_record/aggregations.rb +0 -4
  79. data/test/fixtures/active_record_like/active_record/association.rb +0 -4
  80. data/test/fixtures/active_record_like/active_record/associations/join_dependency.rb +0 -6
  81. data/test/fixtures/active_record_like/active_record/associations/join_dependency/join_base.rb +0 -8
  82. data/test/fixtures/active_record_like/active_record/associations/join_dependency/join_part.rb +0 -8
  83. data/test/fixtures/active_record_like/active_record/caps/caps.rb +0 -4
  84. data/test/fixtures/ignore_non_ruby_file/class.rb +0 -3
  85. data/test/fixtures/ignore_non_ruby_file/non-ruby.txt +0 -1
  86. data/test/fixtures/includes_excludes/lib/a/a_1.rb +0 -6
  87. data/test/fixtures/includes_excludes/lib/a/a_2.rb +0 -6
  88. data/test/fixtures/includes_excludes/lib/a/x/x_1.rb +0 -8
  89. data/test/fixtures/includes_excludes/lib/b/b_1.rb +0 -6
  90. data/test/fixtures/includes_excludes/lib/root_1.rb +0 -4
  91. data/test/fixtures/inspect_multiple_subdirs/module_a.rb +0 -2
  92. data/test/fixtures/inspect_multiple_subdirs/module_a/class_a.rb +0 -5
  93. data/test/fixtures/inspect_multiple_subdirs/module_b.rb +0 -2
  94. data/test/fixtures/inspect_multiple_subdirs/module_b/class_b.rb +0 -5
  95. data/test/fixtures/inspect_multiple_subdirs/module_b/class_c.rb +0 -5
  96. data/test/fixtures/inspect_package/module_a/module_b/class_in_module.rb +0 -6
  97. data/test/fixtures/parse_file/defs_static_function.rb +0 -96
  98. data/test/fixtures/parse_file/function_within_class.rb +0 -36
  99. data/test/fixtures/parse_file/include_public_methods.rb +0 -127
  100. data/test/fixtures/parse_file/instance_function.rb +0 -17
  101. data/test/fixtures/parse_file/modules.rb +0 -71
  102. data/test/fixtures/parse_file/sclass_static_function.rb +0 -88
  103. data/test/fixtures/parse_file/toplevel_class.rb +0 -13
  104. data/test/fixtures/parse_file/toplevel_function.rb +0 -14
  105. data/test/fixtures/trace_test/trace_program_1.rb +0 -44
  106. data/test/implicit_inspect_test.rb +0 -33
  107. data/test/include_exclude_test.rb +0 -48
  108. data/test/prerecorded_trace_test.rb +0 -76
  109. data/test/trace_test.rb +0 -92
@@ -1,3 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
4
+
1
5
  module AppMap
2
6
  module Rails
3
7
  module ActionHandler
@@ -12,11 +16,11 @@ module AppMap
12
16
  class HTTPServerRequest
13
17
  include ContextKey
14
18
 
15
- class Call < AppMap::Trace::MethodEvent
19
+ class Call < AppMap::Event::MethodEvent
16
20
  attr_accessor :payload
17
21
 
18
22
  def initialize(path, lineno, payload)
19
- super AppMap::Trace::MethodEvent.next_id, :call, HTTPServerRequest, :call, path, lineno, false, Thread.current.object_id
23
+ super AppMap::Event.next_id_counter, :call, HTTPServerRequest, :call, path, lineno, false, Thread.current.object_id
20
24
 
21
25
  self.payload = payload
22
26
  end
@@ -45,18 +49,18 @@ module AppMap
45
49
  def call(_, started, finished, _, payload) # (name, started, finished, unique_id, payload)
46
50
  event = Call.new(__FILE__, __LINE__, payload)
47
51
  Thread.current[context_key] = Context.new(event.id, Time.now)
48
- AppMap::Trace.tracers.record_event(event)
52
+ AppMap.tracing.record_event(event)
49
53
  end
50
54
  end
51
55
 
52
56
  class HTTPServerResponse
53
57
  include ContextKey
54
58
 
55
- class Call < AppMap::Trace::MethodReturnIgnoreValue
59
+ class Call < AppMap::Event::MethodReturnIgnoreValue
56
60
  attr_accessor :payload
57
61
 
58
62
  def initialize(path, lineno, payload, parent_id, elapsed)
59
- super AppMap::Trace::MethodEvent.next_id, :return, HTTPServerResponse, :call, path, lineno, false, Thread.current.object_id
63
+ super AppMap::Event.next_id_counter, :return, HTTPServerResponse, :call, path, lineno, false, Thread.current.object_id
60
64
 
61
65
  self.payload = payload
62
66
  self.parent_id = parent_id
@@ -79,7 +83,7 @@ module AppMap
79
83
  Thread.current[context_key] = nil
80
84
 
81
85
  event = Call.new(__FILE__, __LINE__, payload, context.id, Time.now - context.start_time)
82
- AppMap::Trace.tracers.record_event(event)
86
+ AppMap.tracing.record_event(event)
83
87
  end
84
88
  end
85
89
  end
@@ -1,13 +1,15 @@
1
- require 'appmap/trace/tracer'
1
+ # frozen_string_literal: true
2
+
3
+ require 'appmap/event'
2
4
 
3
5
  module AppMap
4
6
  module Rails
5
7
  class SQLHandler
6
- class SQLCall < AppMap::Trace::MethodEvent
8
+ class SQLCall < AppMap::Event::MethodEvent
7
9
  attr_accessor :payload
8
10
 
9
11
  def initialize(path, lineno, payload)
10
- super AppMap::Trace::MethodEvent.next_id, :call, SQLHandler, :call, path, lineno, false, Thread.current.object_id
12
+ super AppMap::Event.next_id_counter, :call, SQLHandler, :call, path, lineno, false, Thread.current.object_id
11
13
 
12
14
  self.payload = payload
13
15
  end
@@ -26,9 +28,9 @@ module AppMap
26
28
  end
27
29
  end
28
30
 
29
- class SQLReturn < AppMap::Trace::MethodReturnIgnoreValue
31
+ class SQLReturn < AppMap::Event::MethodReturnIgnoreValue
30
32
  def initialize(path, lineno, parent_id, elapsed)
31
- super AppMap::Trace::MethodEvent.next_id, :return, SQLHandler, :call, path, lineno, false, Thread.current.object_id
33
+ super AppMap::Event.next_id_counter, :return, SQLHandler, :call, path, lineno, false, Thread.current.object_id
32
34
 
33
35
  self.parent_id = parent_id
34
36
  self.elapsed = elapsed
@@ -92,7 +94,7 @@ module AppMap
92
94
  end
93
95
 
94
96
  def call(_, started, finished, _, payload) # (name, started, finished, unique_id, payload)
95
- return if AppMap::Trace.tracers.empty?
97
+ return if AppMap.tracing.empty?
96
98
 
97
99
  reentry_key = "#{self.class.name}#call"
98
100
  return if Thread.current[reentry_key] == true
@@ -135,8 +137,8 @@ module AppMap
135
137
  SQLExaminer.examine payload, sql: sql
136
138
 
137
139
  call = SQLCall.new(__FILE__, __LINE__, payload)
138
- AppMap::Trace.tracers.record_event(call)
139
- AppMap::Trace.tracers.record_event(SQLReturn.new(__FILE__, __LINE__, call.id, finished - started))
140
+ AppMap.tracing.record_event(call)
141
+ AppMap.tracing.record_event(SQLReturn.new(__FILE__, __LINE__, call.id, finished - started))
140
142
  ensure
141
143
  Thread.current[reentry_key] = nil
142
144
  end
@@ -1,32 +1,45 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AppMap
4
+ # Railtie connects the AppMap recorder to Rails-specific features.
2
5
  class Railtie < ::Rails::Railtie
3
6
  config.appmap = ActiveSupport::OrderedOptions.new
4
7
 
5
- initializer 'appmap.trace' do |app|
8
+ initializer 'appmap.init' do |_| # params: app
9
+ AppMap.configure
10
+ end
11
+
12
+ # appmap.subscribe subscribes to ActiveSupport Notifications so that they can be recorded as
13
+ # AppMap events.
14
+ initializer 'appmap.subscribe', after: 'appmap.init' do |_| # params: app
15
+ require 'appmap/rails/sql_handler'
16
+ require 'appmap/rails/action_handler'
17
+ ActiveSupport::Notifications.subscribe 'sql.sequel', AppMap::Rails::SQLHandler.new
18
+ ActiveSupport::Notifications.subscribe 'sql.active_record', AppMap::Rails::SQLHandler.new
19
+ ActiveSupport::Notifications.subscribe \
20
+ 'start_processing.action_controller', AppMap::Rails::ActionHandler::HTTPServerRequest.new
21
+ ActiveSupport::Notifications.subscribe \
22
+ 'process_action.action_controller', AppMap::Rails::ActionHandler::HTTPServerResponse.new
23
+ end
24
+
25
+ # appmap.trace begins recording an AppMap trace and writes it to appmap.json.
26
+ # This behavior is only activated if the configuration setting app.config.appmap.enabled
27
+ # is truthy.
28
+ initializer 'appmap.trace', after: 'appmap.subscribe' do |app|
6
29
  lambda do
7
30
  return unless app.config.appmap.enabled
8
31
 
9
- require 'appmap'
10
- require 'appmap/config'
11
- config = AppMap::Config.load_from_file 'appmap.yml'
12
-
13
32
  require 'appmap/command/record'
14
33
  require 'json'
15
- AppMap::Command::Record.new(config).perform do |features, events|
16
- File.open('appmap.json', 'w').write JSON.generate(classMap: features, events: events)
34
+ AppMap::Command::Record.new(AppMap.configuration).perform do |version, metadata, class_map, events|
35
+ appmap = JSON.generate \
36
+ version: version,
37
+ metadata: metadata,
38
+ classMap: class_map,
39
+ events: events
40
+ File.open('appmap.json', 'w').write(appmap)
17
41
  end
18
42
  end.call
19
43
  end
20
-
21
- initializer 'appmap.subscribe', after: 'appmap.trace' do |_| # params: app
22
- lambda do
23
- require 'appmap/rails/sql_handler'
24
- require 'appmap/rails/action_handler'
25
- ActiveSupport::Notifications.subscribe('sql.sequel', AppMap::Rails::SQLHandler.new)
26
- ActiveSupport::Notifications.subscribe('sql.active_record', AppMap::Rails::SQLHandler.new)
27
- ActiveSupport::Notifications.subscribe('start_processing.action_controller', AppMap::Rails::ActionHandler::HTTPServerRequest.new)
28
- ActiveSupport::Notifications.subscribe('process_action.action_controller', AppMap::Rails::ActionHandler::HTTPServerResponse.new)
29
- end.call
30
- end
31
44
  end
32
45
  end
data/lib/appmap/rspec.rb CHANGED
@@ -1,342 +1,319 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'appmap'
4
- require 'appmap/config'
5
- require 'appmap/inspect'
6
- require 'appmap/trace/tracer'
7
-
8
- require 'active_support/inflector/transliterate'
9
-
10
3
  module AppMap
11
4
  # Integration of AppMap with RSpec. When enabled with APPMAP=true, the AppMap tracer will
12
5
  # be activated around each scenario which has the metadata key `:appmap`.
13
6
  module RSpec
14
7
  APPMAP_OUTPUT_DIR = 'tmp/appmap/rspec'
8
+ LOG = false
15
9
 
16
- class Recorder
17
- attr_reader :config, :features, :functions
10
+ def self.metadata
11
+ require 'appmap/command/record'
12
+ @metadata ||= AppMap::Command::Record.detect_metadata
13
+ @metadata.freeze
14
+ @metadata.dup
15
+ end
18
16
 
19
- def initialize
20
- @config = AppMap::Config.load_from_file('appmap.yml')
17
+ module FeatureAnnotations
18
+ def feature
19
+ return nil unless annotations
21
20
 
22
- raise "Missing AppMap configuration setting: 'name'" unless @config.name
21
+ annotations[:feature]
22
+ end
23
23
 
24
- @features = AppMap.inspect(@config)
25
- @functions = @features.map(&:collect_functions).flatten
24
+ def labels
25
+ labels = metadata[:appmap]
26
+ if labels.is_a?(Array)
27
+ labels
28
+ elsif labels.is_a?(String) || labels.is_a?(Symbol)
29
+ [ labels ]
30
+ else
31
+ []
32
+ end
26
33
  end
27
34
 
28
- def setup
29
- FileUtils.mkdir_p APPMAP_OUTPUT_DIR
35
+ def feature_group
36
+ return nil unless annotations
37
+
38
+ annotations[:feature_group]
30
39
  end
31
40
 
32
- def save(example_name, events: nil, feature_name: nil, feature_group_name: nil, labels: nil)
33
- require 'appmap/command/record'
34
- metadata = AppMap::Command::Record.detect_metadata.tap do |m|
35
- m[:name] = example_name
36
- m[:app] = @config.name
37
- m[:feature] = feature_name if feature_name
38
- m[:feature_group] = feature_group_name if feature_group_name
39
- m[:labels] = labels if labels
40
- m[:frameworks] ||= []
41
- m[:frameworks] << {
42
- name: 'rspec',
43
- version: Gem.loaded_specs['rspec-core']&.version&.to_s
44
- }
45
- m[:recorder] = {
46
- name: 'rspec'
47
- }
41
+ def annotations
42
+ metadata.tap do |md|
43
+ description_args_hashes.each do |h|
44
+ md.merge! h
45
+ end
48
46
  end
49
-
50
- appmap = {
51
- version: AppMap::APPMAP_FORMAT_VERSION,
52
- classMap: features,
53
- metadata: metadata,
54
- events: events
55
- }.compact
56
- fname = sanitize_filename(example_name)
57
- File.write(File.join(APPMAP_OUTPUT_DIR, "#{fname}.appmap.json"), JSON.generate(appmap))
58
47
  end
59
48
 
60
- # Cribbed from v5 version of ActiveSupport:Inflector#parameterize:
61
- # https://github.com/rails/rails/blob/v5.2.4/activesupport/lib/active_support/inflector/transliterate.rb#L92
62
- def sanitize_filename(fname, separator: '_')
63
- # Replace accented chars with their ASCII equivalents.
64
- fname = fname.encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
49
+ protected
65
50
 
66
- # Turn unwanted chars into the separator.
67
- fname.gsub!(/[^a-z0-9\-_]+/i, separator)
51
+ def metadata
52
+ return {} unless example_obj.respond_to?(:metadata)
68
53
 
69
- re_sep = Regexp.escape(separator)
70
- re_duplicate_separator = /#{re_sep}{2,}/
71
- re_leading_trailing_separator = /^#{re_sep}|#{re_sep}$/i
54
+ example_obj.metadata
55
+ end
72
56
 
73
- # No more than one of the separator in a row.
74
- fname.gsub!(re_duplicate_separator, separator)
57
+ def description_args_hashes
58
+ return [] unless example_obj.respond_to?(:metadata)
75
59
 
76
- # Finally, Remove leading/trailing separator.
77
- fname.gsub(re_leading_trailing_separator, '')
60
+ (example_obj.metadata[:description_args] || []).select { |arg| arg.is_a?(Hash) }
78
61
  end
79
62
  end
80
63
 
81
- class << self
82
- module FeatureAnnotations
83
- def feature
84
- return nil unless annotations
64
+ # ScopeExample and ScopeExampleGroup is a way to handle the weird way that RSpec
65
+ # stores the nested example names.
66
+ ScopeExample = Struct.new(:example) do
67
+ include FeatureAnnotations
85
68
 
86
- annotations[:feature]
87
- end
69
+ alias_method :example_obj, :example
88
70
 
89
- def labels
90
- labels = metadata[:appmap]
91
- if labels.is_a?(Array)
92
- labels
93
- elsif labels.is_a?(String) || labels.is_a?(Symbol)
94
- [ labels ]
95
- else
96
- []
97
- end
98
- end
71
+ def description?
72
+ true
73
+ end
99
74
 
100
- def feature_group
101
- return nil unless annotations
75
+ def description
76
+ example.description
77
+ end
102
78
 
103
- annotations[:feature_group]
104
- end
79
+ def parent
80
+ ScopeExampleGroup.new(example.example_group)
81
+ end
82
+ end
105
83
 
106
- def annotations
107
- metadata.tap do |md|
108
- description_args_hashes.each do |h|
109
- md.merge! h
110
- end
111
- end
112
- end
84
+ # As you can see here, the way that RSpec stores the example description and
85
+ # represents the example group hierarchy is pretty weird.
86
+ ScopeExampleGroup = Struct.new(:example_group) do
87
+ include FeatureAnnotations
113
88
 
114
- protected
89
+ alias_method :example_obj, :example_group
115
90
 
116
- def metadata
117
- return {} unless example_obj.respond_to?(:metadata)
91
+ def description_args
92
+ # Don't stringify any hashes that RSpec considers part of the example group description.
93
+ example_group.metadata[:description_args].reject { |arg| arg.is_a?(Hash) }
94
+ end
118
95
 
119
- example_obj.metadata
120
- end
96
+ def description?
97
+ return true if example_group.respond_to?(:described_class) && example_group.described_class
121
98
 
122
- def description_args_hashes
123
- return [] unless example_obj.respond_to?(:metadata)
99
+ return true if example_group.respond_to?(:description) && !description_args.empty?
124
100
 
125
- (example_obj.metadata[:description_args] || []).select { |arg| arg.is_a?(Hash) }
126
- end
101
+ false
127
102
  end
128
103
 
129
- # ScopeExample and ScopeExampleGroup is a way to handle the weird way that RSpec
130
- # stores the nested example names.
131
- ScopeExample = Struct.new(:example) do
132
- include FeatureAnnotations
133
-
134
- alias_method :example_obj, :example
104
+ def description
105
+ description? ? description_args.join(' ') : nil
106
+ end
135
107
 
136
- def description?
137
- true
138
- end
108
+ def parent
109
+ # An example group always has a parent; but it might be 'self'...
110
+ example_group.parent != example_group ? ScopeExampleGroup.new(example_group.parent) : nil
111
+ end
112
+ end
139
113
 
140
- def description
141
- example.description
142
- end
114
+ Recording = Struct.new(:example) do
115
+ def initialize(example)
116
+ super
143
117
 
144
- def parent
145
- ScopeExampleGroup.new(example.example_group)
146
- end
118
+ warn "Starting recording of example #{example}" if AppMap::RSpec::LOG
119
+ @trace = AppMap.tracing.trace
147
120
  end
148
121
 
149
- # As you can see here, the way that RSpec stores the example description and
150
- # represents the example group hierarchy is pretty weird.
151
- ScopeExampleGroup = Struct.new(:example_group) do
152
- include FeatureAnnotations
122
+ def finish
123
+ warn "Finishing recording of example #{example}" if AppMap::RSpec::LOG
153
124
 
154
- alias_method :example_obj, :example_group
125
+ events = []
126
+ AppMap.tracing.delete @trace
155
127
 
156
- def description_args
157
- # Don't stringify any hashes that RSpec considers part of the example group description.
158
- example_group.metadata[:description_args].reject { |arg| arg.is_a?(Hash) }
159
- end
128
+ events << @trace.next_event.to_h while @trace.event?
129
+
130
+ AppMap::RSpec.add_event_methods @trace.event_methods
160
131
 
161
- def description?
162
- return true if example_group.respond_to?(:described_class) && example_group.described_class
132
+ class_map = AppMap.class_map(AppMap::RSpec.config, @trace.event_methods)
163
133
 
164
- return true if example_group.respond_to?(:description) && !description_args.empty?
134
+ description = []
135
+ scope = ScopeExample.new(example)
136
+ feature_group = feature = nil
165
137
 
166
- false
138
+ labels = []
139
+ while scope
140
+ labels += scope.labels
141
+ description << scope.description
142
+ feature ||= scope.feature
143
+ feature_group ||= scope.feature_group
144
+ scope = scope.parent
167
145
  end
168
146
 
169
- def description
170
- description? ? description_args.join(' ') : nil
147
+ labels = labels.map(&:to_s).map(&:strip).reject(&:blank?).map(&:downcase).uniq
148
+ description.reject!(&:nil?).reject(&:blank?)
149
+ default_description = description.last
150
+ description.reverse!
151
+
152
+ normalize = lambda do |desc|
153
+ desc.gsub('it should behave like', '')
154
+ .gsub(/Controller$/, '')
155
+ .gsub(/\s+/, ' ')
156
+ .strip
171
157
  end
172
158
 
173
- def parent
174
- # An example group always has a parent; but it might be 'self'...
175
- example_group.parent != example_group ? ScopeExampleGroup.new(example_group.parent) : nil
159
+ full_description = normalize.call(description.join(' '))
160
+
161
+ compute_feature_name = lambda do
162
+ return 'unknown' if description.empty?
163
+
164
+ feature_description = description.dup
165
+ num_tokens = [2, feature_description.length - 1].min
166
+ feature_description[0...num_tokens].map(&:strip).join(' ')
176
167
  end
168
+
169
+ feature_group ||= normalize.call(default_description).underscore.gsub('/', '_').humanize
170
+ feature_name = feature || compute_feature_name.call if feature_group
171
+ feature_name = normalize.call(feature_name) if feature_name
172
+
173
+ AppMap::RSpec.save full_description,
174
+ class_map,
175
+ events: events,
176
+ feature_name: feature_name,
177
+ feature_group_name: feature_group,
178
+ labels: labels.blank? ? nil : labels
177
179
  end
180
+ end
181
+
182
+ @recordings_by_example = {}
183
+ @config = nil
184
+ @event_methods = Set.new
185
+
186
+ class << self
187
+ def init
188
+ warn 'Configuring AppMap recorder for RSpec'
178
189
 
179
- LOG = false
180
-
181
- def is_example_group_subclass_call?(tp)
182
- # Order is important here. Checking for method_id == :subclass
183
- # first will avoid calling defined_class.to_s in many cases,
184
- # some of which will fail.
185
- #
186
- # For example, ActiveRecord in Rails 4 defines #inspect (and
187
- # therefore #to_s) in such a way that it will fail if called
188
- # here.
189
- tp.event == :call &&
190
- tp.method_id == :subclass &&
191
- tp.defined_class.singleton_class? &&
192
- tp.defined_class.to_s == '#<Class:RSpec::Core::ExampleGroup>'
190
+ FileUtils.mkdir_p APPMAP_OUTPUT_DIR
191
+
192
+ require 'appmap/hook'
193
+ @config = AppMap.configure
194
+ AppMap::Hook.hook(@config)
193
195
  end
194
196
 
195
- def is_example_initialize_call?(tp)
196
- tp.event == :call &&
197
- tp.method_id == :initialize &&
198
- tp.defined_class.to_s == 'RSpec::Core::Example'
197
+ def begin_spec(example)
198
+ @recordings_by_example[example.object_id] = Recording.new(example)
199
199
  end
200
200
 
201
- def generate_inventory
202
- Recorder.new.tap do |recorder|
203
- recorder.setup
204
- end.save 'Inventory', labels: %w[inventory]
201
+ def end_spec(example)
202
+ recording = @recordings_by_example.delete(example.object_id)
203
+ return warn "No recording found for #{example}" unless recording
204
+
205
+ recording.finish
205
206
  end
206
207
 
207
- def generate_appmaps_from_specs
208
- recorder = Recorder.new
209
- recorder.setup
210
-
211
- require 'set'
212
- # file:lineno at which an Example block begins
213
- trace_block_start = Set.new
214
- # file:lineno at which an Example block ends
215
- trace_block_end = Set.new
216
-
217
- # value: a BlockParseNode from an RSpec file
218
- # key: file:lineno at which the block begins
219
- rspec_blocks = {}
220
-
221
- # value: an Example instance
222
- # key: file:lineno at which the Example block ends
223
- examples = {}
224
-
225
- current_tracer = nil
226
-
227
- TracePoint.trace(:call, :b_call, :b_return) do |tp|
228
- # When a new ExampleGroup is encountered, parse the source file containing it and look
229
- # for blocks that might be Examples. Index each BlockParseNode by the start file:lineno.
230
- if is_example_group_subclass_call?(tp)
231
- example_block = tp.binding.eval('example_group_block')
232
- source_path, start_line = example_block.source_location
233
- require 'appmap/rspec/parser'
234
- nodes, = AppMap::RSpec::Parser.new(file_path: source_path).parse
235
- nodes.each do |node|
236
- start_loc = [ node.file_path, node.first_line ].join(':')
237
- rspec_blocks[start_loc] = node
238
- end
239
- end
208
+ def config
209
+ @config or raise "AppMap is not configured"
210
+ end
240
211
 
241
- # When a new Example is constructed with a block, look for the BlockParseNode that starts at the block's
242
- # file:lineno. If it exists, store the Example object, indexed by the file:lineno at which it ends.
243
- if is_example_initialize_call?(tp)
244
- example_block = tp.binding.eval('example_block')
245
- if example_block
246
- source_path, start_line = example_block.source_location
247
- start_loc = [ source_path, start_line ].join(':')
248
- if (rspec_block = rspec_blocks[start_loc])
249
- end_loc = [ source_path, rspec_block.last_line ].join(':')
250
- trace_block_start << start_loc.tap { |loc| puts "Start: #{loc}" if LOG }
251
- trace_block_end << end_loc.tap { |loc| puts "End: #{loc}" if LOG }
252
- examples[end_loc] = tp.binding.eval('self')
253
- end
254
- end
255
- end
212
+ def add_event_methods(event_methods)
213
+ @event_methods += event_methods
214
+ end
256
215
 
257
- if %i[b_call b_return].member?(tp.event)
258
- loc = [ tp.path, tp.lineno ].join(':')
259
- puts loc if LOG && (trace_block_start.member?(loc) || trace_block_end.member?(loc))
216
+ def save(example_name, class_map, events: nil, feature_name: nil, feature_group_name: nil, labels: nil)
217
+ metadata = RSpec.metadata.dup.tap do |m|
218
+ m[:name] = example_name
219
+ m[:app] = @config.name
220
+ m[:feature] = feature_name if feature_name
221
+ m[:feature_group] = feature_group_name if feature_group_name
222
+ m[:labels] = labels if labels
223
+ m[:frameworks] ||= []
224
+ m[:frameworks] << {
225
+ name: 'rspec',
226
+ version: Gem.loaded_specs['rspec-core']&.version&.to_s
227
+ }
228
+ m[:recorder] = {
229
+ name: 'rspec'
230
+ }
231
+ end
260
232
 
261
- # When a new block is started, check if an Example block is known to begin at that
262
- # file:lineno. If it is, enable the AppMap tracer.
263
- if tp.event == :b_call && trace_block_start.member?(loc)
264
- puts "Starting trace on #{loc}" if LOG
265
- current_tracer = AppMap::Trace.tracers.trace(recorder.functions)
266
- end
233
+ appmap = {
234
+ version: AppMap::APPMAP_FORMAT_VERSION,
235
+ metadata: metadata,
236
+ classMap: class_map,
237
+ events: events
238
+ }.compact
239
+ fname = sanitize_filename(example_name)
267
240
 
268
- # When the tracer is enabled and a block is completed, check to see if there is an
269
- # Example stored at the file:lineno. If so, finish tracing and emit the
270
- # AppMap file.
271
- if current_tracer && tp.event == :b_return && trace_block_end.member?(loc)
272
- puts "Ending trace on #{loc}" if LOG
273
- events = []
274
- AppMap::Trace.tracers.delete current_tracer
241
+ File.write(File.join(APPMAP_OUTPUT_DIR, "#{fname}.appmap.json"), JSON.generate(appmap))
242
+ end
275
243
 
276
- while current_tracer.event?
277
- events << current_tracer.next_event.to_h
278
- end
244
+ def print_inventory
245
+ class_map = AppMap.class_map(@config, @event_methods)
246
+ save 'Inventory', class_map, labels: %w[inventory]
247
+ end
279
248
 
280
- example = examples[loc]
281
- description = []
282
- leaf = scope = ScopeExample.new(example)
283
- feature_group = feature = nil
284
-
285
- labels = []
286
- while scope
287
- labels += scope.labels
288
- description << scope.description
289
- feature ||= scope.feature
290
- feature_group ||= scope.feature_group
291
- scope = scope.parent
292
- end
249
+ def enabled?
250
+ ENV['APPMAP'] == 'true'
251
+ end
293
252
 
294
- labels = labels.map(&:to_s).map(&:strip).reject(&:blank?).map(&:downcase).uniq
295
- description.reject!(&:nil?).reject(&:blank?)
296
- default_description = description.last
297
- description.reverse!
253
+ def run
254
+ init
255
+ at_exit do
256
+ print_inventory
257
+ end
258
+ end
298
259
 
299
- normalize = lambda do |desc|
300
- desc.gsub('it should behave like', '')
301
- .gsub(/Controller$/, '')
302
- .gsub(/\s+/, ' ')
303
- .strip
304
- end
260
+ private
305
261
 
306
- full_description = normalize.call(description.join(' '))
262
+ # Cribbed from v5 version of ActiveSupport:Inflector#parameterize:
263
+ # https://github.com/rails/rails/blob/v5.2.4/activesupport/lib/active_support/inflector/transliterate.rb#L92
264
+ def sanitize_filename(fname, separator: '_')
265
+ # Replace accented chars with their ASCII equivalents.
266
+ fname = fname.encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
307
267
 
308
- compute_feature_name = lambda do
309
- return 'unknown' if description.empty?
268
+ # Turn unwanted chars into the separator.
269
+ fname.gsub!(/[^a-z0-9\-_]+/i, separator)
310
270
 
311
- feature_description = description.dup
312
- num_tokens = [2, feature_description.length - 1].min
313
- feature_description[0...num_tokens].map(&:strip).join(' ')
314
- end
271
+ re_sep = Regexp.escape(separator)
272
+ re_duplicate_separator = /#{re_sep}{2,}/
273
+ re_leading_trailing_separator = /^#{re_sep}|#{re_sep}$/i
274
+
275
+ # No more than one of the separator in a row.
276
+ fname.gsub!(re_duplicate_separator, separator)
315
277
 
316
- feature_group ||= normalize.call(default_description).underscore.gsub('/', '_').humanize
317
- feature_name = feature || compute_feature_name.call if feature_group
318
- feature_name = normalize.call(feature_name) if feature_name
278
+ # Finally, Remove leading/trailing separator.
279
+ fname.gsub(re_leading_trailing_separator, '')
280
+ end
281
+ end
282
+ end
283
+ end
284
+
285
+ if AppMap::RSpec.enabled?
286
+ require 'appmap'
287
+ require 'active_support/inflector/transliterate'
288
+ require 'rspec/core'
289
+ require 'rspec/core/example'
319
290
 
320
- recorder.save full_description,
321
- events: events,
322
- feature_name: feature_name,
323
- feature_group_name: feature_group,
324
- labels: labels.blank? ? nil : labels
291
+ module RSpec
292
+ module Core
293
+ class Example
294
+ class << self
295
+ def wrap_example_block(example, fn)
296
+ proc do
297
+ AppMap::RSpec.begin_spec example
298
+ begin
299
+ instance_exec(&fn)
300
+ ensure
301
+ AppMap::RSpec.end_spec example
302
+ end
325
303
  end
326
304
  end
327
305
  end
328
- end
329
306
 
330
- def enabled?
331
- ENV['APPMAP'] == 'true'
332
- end
333
-
334
- def run
335
- generate_inventory
336
- generate_appmaps_from_specs
307
+ def self.new(*arguments, &block)
308
+ warn "Wrapping example_block for #{name}" if AppMap::RSpec::LOG
309
+ allocate.tap do |obj|
310
+ arguments[arguments.length - 1] = wrap_example_block(obj, arguments.last) if arguments.last.is_a?(Proc)
311
+ obj.send :initialize, *arguments, &block
312
+ end
313
+ end
337
314
  end
338
315
  end
339
316
  end
340
- end
341
317
 
342
- AppMap::RSpec.run if AppMap::RSpec.enabled?
318
+ AppMap::RSpec.run
319
+ end