appmap 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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