appmap 0.23.0 → 0.27.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +17 -8
  4. data/.travis.yml +6 -0
  5. data/CHANGELOG.md +43 -0
  6. data/README.md +33 -21
  7. data/Rakefile +3 -3
  8. data/appmap.gemspec +3 -1
  9. data/exe/appmap +5 -73
  10. data/lib/appmap.rb +61 -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/event.rb +168 -0
  17. data/lib/appmap/hook.rb +152 -0
  18. data/lib/appmap/middleware/remote_recording.rb +14 -21
  19. data/lib/appmap/rails/action_handler.rb +10 -6
  20. data/lib/appmap/rails/sql_handler.rb +10 -13
  21. data/lib/appmap/railtie.rb +31 -18
  22. data/lib/appmap/rspec.rb +247 -260
  23. data/lib/appmap/trace.rb +88 -0
  24. data/lib/appmap/version.rb +1 -1
  25. data/package-lock.json +90 -92
  26. data/spec/abstract_controller4_base_spec.rb +1 -1
  27. data/spec/abstract_controller_base_spec.rb +7 -3
  28. data/spec/config_spec.rb +25 -0
  29. data/spec/fixtures/hook/attr_accessor.rb +5 -0
  30. data/spec/fixtures/hook/class_method.rb +17 -0
  31. data/spec/fixtures/hook/constructor.rb +7 -0
  32. data/spec/fixtures/hook/exception_method.rb +11 -0
  33. data/spec/fixtures/hook/instance_method.rb +23 -0
  34. data/spec/fixtures/rails4_users_app/app/controllers/api/users_controller.rb +3 -3
  35. data/spec/fixtures/rails4_users_app/config/database.yml +2 -1
  36. data/spec/fixtures/rails4_users_app/docker-compose.yml +2 -0
  37. data/spec/fixtures/rails_users_app/.ruby-version +1 -1
  38. data/spec/fixtures/rails_users_app/app/controllers/api/users_controller.rb +2 -2
  39. data/spec/fixtures/rails_users_app/config/database.yml +2 -1
  40. data/spec/fixtures/rails_users_app/create_app +1 -0
  41. data/spec/fixtures/rails_users_app/docker-compose.yml +4 -0
  42. data/spec/fixtures/rails_users_app/spec/models/user_spec.rb +1 -1
  43. data/spec/hook_spec.rb +369 -0
  44. data/spec/rails_spec_helper.rb +25 -16
  45. data/spec/railtie_spec.rb +1 -1
  46. data/spec/record_sql_rails_pg_spec.rb +1 -2
  47. data/spec/remote_recording_spec.rb +117 -0
  48. data/spec/spec_helper.rb +5 -0
  49. data/test/cli_test.rb +4 -46
  50. data/test/fixtures/cli_record_test/appmap.yml +2 -1
  51. data/test/fixtures/cli_record_test/lib/cli_record_test/main.rb +4 -2
  52. data/test/fixtures/rspec_recorder/Gemfile +1 -1
  53. data/test/fixtures/rspec_recorder/spec/decorated_hello_spec.rb +12 -0
  54. data/test/rspec_test.rb +5 -0
  55. data/test/test_helper.rb +0 -42
  56. metadata +46 -63
  57. data/exe/_appmap-record-self +0 -49
  58. data/lib/appmap/command/inspect.rb +0 -14
  59. data/lib/appmap/command/upload.rb +0 -99
  60. data/lib/appmap/config.rb +0 -65
  61. data/lib/appmap/config/directory.rb +0 -65
  62. data/lib/appmap/config/file.rb +0 -13
  63. data/lib/appmap/config/named_function.rb +0 -21
  64. data/lib/appmap/config/package_dir.rb +0 -52
  65. data/lib/appmap/config/path.rb +0 -25
  66. data/lib/appmap/feature.rb +0 -262
  67. data/lib/appmap/inspect.rb +0 -91
  68. data/lib/appmap/inspect/inspector.rb +0 -99
  69. data/lib/appmap/inspect/parse_node.rb +0 -170
  70. data/lib/appmap/inspect/parser.rb +0 -15
  71. data/lib/appmap/parser.rb +0 -60
  72. data/lib/appmap/rspec/parse_node.rb +0 -41
  73. data/lib/appmap/rspec/parser.rb +0 -15
  74. data/lib/appmap/trace/event_handler/rack_handler_webrick.rb +0 -65
  75. data/lib/appmap/trace/tracer.rb +0 -356
  76. data/spec/fixtures/rails_users_app/bin/_appmap-record-self +0 -29
  77. data/spec/rack_handler_webrick_spec.rb +0 -59
  78. data/test/config_test.rb +0 -149
  79. data/test/explict_inspect_test.rb +0 -29
  80. data/test/fixtures/active_record_like/active_record.rb +0 -2
  81. data/test/fixtures/active_record_like/active_record/aggregations.rb +0 -4
  82. data/test/fixtures/active_record_like/active_record/association.rb +0 -4
  83. data/test/fixtures/active_record_like/active_record/associations/join_dependency.rb +0 -6
  84. data/test/fixtures/active_record_like/active_record/associations/join_dependency/join_base.rb +0 -8
  85. data/test/fixtures/active_record_like/active_record/associations/join_dependency/join_part.rb +0 -8
  86. data/test/fixtures/active_record_like/active_record/caps/caps.rb +0 -4
  87. data/test/fixtures/ignore_non_ruby_file/class.rb +0 -3
  88. data/test/fixtures/ignore_non_ruby_file/non-ruby.txt +0 -1
  89. data/test/fixtures/includes_excludes/lib/a/a_1.rb +0 -6
  90. data/test/fixtures/includes_excludes/lib/a/a_2.rb +0 -6
  91. data/test/fixtures/includes_excludes/lib/a/x/x_1.rb +0 -8
  92. data/test/fixtures/includes_excludes/lib/b/b_1.rb +0 -6
  93. data/test/fixtures/includes_excludes/lib/root_1.rb +0 -4
  94. data/test/fixtures/inspect_multiple_subdirs/module_a.rb +0 -2
  95. data/test/fixtures/inspect_multiple_subdirs/module_a/class_a.rb +0 -5
  96. data/test/fixtures/inspect_multiple_subdirs/module_b.rb +0 -2
  97. data/test/fixtures/inspect_multiple_subdirs/module_b/class_b.rb +0 -5
  98. data/test/fixtures/inspect_multiple_subdirs/module_b/class_c.rb +0 -5
  99. data/test/fixtures/inspect_package/module_a/module_b/class_in_module.rb +0 -6
  100. data/test/fixtures/parse_file/defs_static_function.rb +0 -96
  101. data/test/fixtures/parse_file/function_within_class.rb +0 -36
  102. data/test/fixtures/parse_file/include_public_methods.rb +0 -127
  103. data/test/fixtures/parse_file/instance_function.rb +0 -17
  104. data/test/fixtures/parse_file/modules.rb +0 -71
  105. data/test/fixtures/parse_file/sclass_static_function.rb +0 -88
  106. data/test/fixtures/parse_file/toplevel_class.rb +0 -13
  107. data/test/fixtures/parse_file/toplevel_function.rb +0 -14
  108. data/test/fixtures/trace_test/trace_program_1.rb +0 -44
  109. data/test/implicit_inspect_test.rb +0 -33
  110. data/test/include_exclude_test.rb +0 -48
  111. data/test/prerecorded_trace_test.rb +0 -76
  112. data/test/trace_test.rb +0 -92
@@ -4,17 +4,13 @@ module AppMap
4
4
  module Middleware
5
5
  # RemoteRecording adds `/_appmap/record` routes to control recordings via HTTP requests
6
6
  class RemoteRecording
7
-
8
7
  def initialize(app)
9
8
  require 'appmap/command/record'
10
- require 'appmap/command/upload'
11
- require 'appmap/trace/tracer'
12
- require 'appmap/config'
13
9
  require 'json'
14
10
 
15
11
  @app = app
16
- @features = AppMap.inspect(config)
17
- @functions = @features.map(&:collect_functions).flatten
12
+ @config = AppMap.configure
13
+ AppMap::Hook.hook(@config)
18
14
  end
19
15
 
20
16
  def event_loop
@@ -29,23 +25,23 @@ module AppMap
29
25
  end
30
26
 
31
27
  def start_recording
32
- return [ false, 'Recording is already in progress' ] if @tracer
28
+ return [ 409, 'Recording is already in progress' ] if @tracer
33
29
 
34
30
  @events = []
35
- @tracer = AppMap::Trace.tracers.trace(@functions)
31
+ @tracer = AppMap.tracing.trace
36
32
  @event_thread = Thread.new { event_loop }
37
33
  @event_thread.abort_on_exception = true
38
34
 
39
- [ true ]
35
+ [ 200 ]
40
36
  end
41
37
 
42
38
  def stop_recording(req)
43
- return [ false, 'No recording is in progress' ] unless @tracer
39
+ return [ 404, 'No recording is in progress' ] unless @tracer
44
40
 
45
41
  tracer = @tracer
46
42
  @tracer = nil
47
43
 
48
- AppMap::Trace.tracers.delete(tracer)
44
+ AppMap.tracing.delete(tracer)
49
45
 
50
46
  @event_thread.exit
51
47
  @event_thread.join
@@ -73,9 +69,13 @@ module AppMap
73
69
  name: 'remote_recording'
74
70
  }
75
71
 
76
- response = JSON.generate(version: AppMap::APPMAP_FORMAT_VERSION, classMap: @features, metadata: metadata, events: @events)
72
+ response = JSON.generate \
73
+ version: AppMap::APPMAP_FORMAT_VERSION,
74
+ classMap: AppMap.class_map(@config, tracer.event_methods),
75
+ metadata: metadata,
76
+ events: @events
77
77
 
78
- [ true, response ]
78
+ [ 200, response ]
79
79
  end
80
80
 
81
81
  def call(env)
@@ -103,20 +103,13 @@ module AppMap
103
103
  [ 404, '' ]
104
104
  end
105
105
 
106
- status = 200 if status == true
107
- status = 500 if status == false
108
-
109
- [status, { 'Content-Type' => 'application/text' }, [body || '']]
106
+ [status, { 'Content-Type' => 'application/json' }, [body || '']]
110
107
  end
111
108
 
112
109
  def html_response?(headers)
113
110
  headers['Content-Type'] && headers['Content-Type'] =~ /html/
114
111
  end
115
112
 
116
- def config
117
- @config ||= AppMap::Config.load_from_file 'appmap.yml'
118
- end
119
-
120
113
  def recording?
121
114
  !@event_thread.nil?
122
115
  end
@@ -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
@@ -100,9 +102,6 @@ module AppMap
100
102
  Thread.current[reentry_key] = true
101
103
  begin
102
104
  sql = payload[:sql].strip
103
- sql_upper = sql.upcase
104
-
105
- return unless WHITELIST.find { |keyword| sql_upper.index(keyword) == 0 }
106
105
 
107
106
  # Detect whether a function call within a specified filename is present in the call stack.
108
107
  find_in_backtrace = lambda do |file_name, function_name = nil|
@@ -135,14 +134,12 @@ module AppMap
135
134
  SQLExaminer.examine payload, sql: sql
136
135
 
137
136
  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))
137
+ AppMap.tracing.record_event(call)
138
+ AppMap.tracing.record_event(SQLReturn.new(__FILE__, __LINE__, call.id, finished - started))
140
139
  ensure
141
140
  Thread.current[reentry_key] = nil
142
141
  end
143
142
  end
144
-
145
- WHITELIST = %w[SELECT INSERT UPDATE DELETE].freeze
146
143
  end
147
144
  end
148
145
  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
@@ -1,342 +1,329 @@
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.deep_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
104
+ def description
105
+ description? ? description_args.join(' ') : nil
106
+ end
133
107
 
134
- alias_method :example_obj, :example
108
+ def parent
109
+ # An example group always has a parent; but it might be 'self'...
135
110
 
136
- def description?
137
- true
138
- end
111
+ # DEPRECATION WARNING: `Module#parent` has been renamed to `module_parent`. `parent` is deprecated and will be
112
+ # removed in Rails 6.1. (called from parent at /Users/kgilpin/source/appland/appmap-ruby/lib/appmap/rspec.rb:110)
113
+ example_group_parent = \
114
+ if example_group.respond_to?(:module_parent)
115
+ example_group.module_parent
116
+ else
117
+ example_group.parent
118
+ end
139
119
 
140
- def description
141
- example.description
142
- end
120
+ example_group_parent != example_group ? ScopeExampleGroup.new(example_group_parent) : nil
121
+ end
122
+ end
143
123
 
144
- def parent
145
- ScopeExampleGroup.new(example.example_group)
146
- end
124
+ Recording = Struct.new(:example) do
125
+ def initialize(example)
126
+ super
127
+
128
+ warn "Starting recording of example #{example}" if AppMap::RSpec::LOG
129
+ @trace = AppMap.tracing.trace
147
130
  end
148
131
 
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
132
+ def finish
133
+ warn "Finishing recording of example #{example}" if AppMap::RSpec::LOG
153
134
 
154
- alias_method :example_obj, :example_group
135
+ events = []
136
+ AppMap.tracing.delete @trace
155
137
 
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
138
+ events << @trace.next_event.to_h while @trace.event?
139
+
140
+ AppMap::RSpec.add_event_methods @trace.event_methods
160
141
 
161
- def description?
162
- return true if example_group.respond_to?(:described_class) && example_group.described_class
142
+ class_map = AppMap.class_map(AppMap::RSpec.config, @trace.event_methods)
163
143
 
164
- return true if example_group.respond_to?(:description) && !description_args.empty?
144
+ description = []
145
+ scope = ScopeExample.new(example)
146
+ feature_group = feature = nil
165
147
 
166
- false
148
+ labels = []
149
+ while scope
150
+ labels += scope.labels
151
+ description << scope.description
152
+ feature ||= scope.feature
153
+ feature_group ||= scope.feature_group
154
+ scope = scope.parent
167
155
  end
168
156
 
169
- def description
170
- description? ? description_args.join(' ') : nil
157
+ labels = labels.map(&:to_s).map(&:strip).reject(&:blank?).map(&:downcase).uniq
158
+ description.reject!(&:nil?).reject(&:blank?)
159
+ default_description = description.last
160
+ description.reverse!
161
+
162
+ normalize = lambda do |desc|
163
+ desc.gsub('it should behave like', '')
164
+ .gsub(/Controller$/, '')
165
+ .gsub(/\s+/, ' ')
166
+ .strip
171
167
  end
172
168
 
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
169
+ full_description = normalize.call(description.join(' '))
170
+
171
+ compute_feature_name = lambda do
172
+ return 'unknown' if description.empty?
173
+
174
+ feature_description = description.dup
175
+ num_tokens = [2, feature_description.length - 1].min
176
+ feature_description[0...num_tokens].map(&:strip).join(' ')
176
177
  end
178
+
179
+ feature_group ||= normalize.call(default_description).underscore.gsub('/', '_').humanize
180
+ feature_name = feature || compute_feature_name.call if feature_group
181
+ feature_name = normalize.call(feature_name) if feature_name
182
+
183
+ AppMap::RSpec.save full_description,
184
+ class_map,
185
+ events: events,
186
+ feature_name: feature_name,
187
+ feature_group_name: feature_group,
188
+ labels: labels.blank? ? nil : labels
189
+ end
190
+ end
191
+
192
+ @recordings_by_example = {}
193
+ @config = nil
194
+ @event_methods = Set.new
195
+
196
+ class << self
197
+ def init
198
+ warn 'Configuring AppMap recorder for RSpec'
199
+
200
+ FileUtils.mkdir_p APPMAP_OUTPUT_DIR
201
+
202
+ require 'appmap/hook'
203
+ @config = AppMap.configure
204
+ AppMap::Hook.hook(@config)
177
205
  end
178
206
 
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>'
207
+ def begin_spec(example)
208
+ @recordings_by_example[example.object_id] = Recording.new(example)
193
209
  end
194
210
 
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'
211
+ def end_spec(example)
212
+ recording = @recordings_by_example.delete(example.object_id)
213
+ return warn "No recording found for #{example}" unless recording
214
+
215
+ recording.finish
199
216
  end
200
217
 
201
- def generate_inventory
202
- Recorder.new.tap do |recorder|
203
- recorder.setup
204
- end.save 'Inventory', labels: %w[inventory]
218
+ def config
219
+ @config or raise "AppMap is not configured"
205
220
  end
206
221
 
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
222
+ def add_event_methods(event_methods)
223
+ @event_methods += event_methods
224
+ end
240
225
 
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
226
+ def save(example_name, class_map, events: nil, feature_name: nil, feature_group_name: nil, labels: nil)
227
+ metadata = RSpec.metadata.tap do |m|
228
+ m[:name] = example_name
229
+ m[:app] = @config.name
230
+ m[:feature] = feature_name if feature_name
231
+ m[:feature_group] = feature_group_name if feature_group_name
232
+ m[:labels] = labels if labels
233
+ m[:frameworks] ||= []
234
+ m[:frameworks] << {
235
+ name: 'rspec',
236
+ version: Gem.loaded_specs['rspec-core']&.version&.to_s
237
+ }
238
+ m[:recorder] = {
239
+ name: 'rspec'
240
+ }
241
+ end
256
242
 
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))
243
+ appmap = {
244
+ version: AppMap::APPMAP_FORMAT_VERSION,
245
+ metadata: metadata,
246
+ classMap: class_map,
247
+ events: events
248
+ }.compact
249
+ fname = sanitize_filename(example_name)
260
250
 
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
251
+ File.write(File.join(APPMAP_OUTPUT_DIR, "#{fname}.appmap.json"), JSON.generate(appmap))
252
+ end
267
253
 
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
254
+ def print_inventory
255
+ class_map = AppMap.class_map(@config, @event_methods)
256
+ save 'Inventory', class_map, labels: %w[inventory]
257
+ end
275
258
 
276
- while current_tracer.event?
277
- events << current_tracer.next_event.to_h
278
- end
259
+ def enabled?
260
+ ENV['APPMAP'] == 'true'
261
+ end
279
262
 
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
263
+ def run
264
+ init
265
+ at_exit do
266
+ print_inventory
267
+ end
268
+ end
293
269
 
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!
270
+ private
298
271
 
299
- normalize = lambda do |desc|
300
- desc.gsub('it should behave like', '')
301
- .gsub(/Controller$/, '')
302
- .gsub(/\s+/, ' ')
303
- .strip
304
- end
272
+ # Cribbed from v5 version of ActiveSupport:Inflector#parameterize:
273
+ # https://github.com/rails/rails/blob/v5.2.4/activesupport/lib/active_support/inflector/transliterate.rb#L92
274
+ def sanitize_filename(fname, separator: '_')
275
+ # Replace accented chars with their ASCII equivalents.
276
+ fname = fname.encode('utf-8', invalid: :replace, undef: :replace, replace: '_')
305
277
 
306
- full_description = normalize.call(description.join(' '))
278
+ # Turn unwanted chars into the separator.
279
+ fname.gsub!(/[^a-z0-9\-_]+/i, separator)
307
280
 
308
- compute_feature_name = lambda do
309
- return 'unknown' if description.empty?
281
+ re_sep = Regexp.escape(separator)
282
+ re_duplicate_separator = /#{re_sep}{2,}/
283
+ re_leading_trailing_separator = /^#{re_sep}|#{re_sep}$/i
310
284
 
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
285
+ # No more than one of the separator in a row.
286
+ fname.gsub!(re_duplicate_separator, separator)
315
287
 
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
288
+ # Finally, Remove leading/trailing separator.
289
+ fname.gsub(re_leading_trailing_separator, '')
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ if AppMap::RSpec.enabled?
296
+ require 'appmap'
297
+ require 'active_support/inflector/transliterate'
298
+ require 'rspec/core'
299
+ require 'rspec/core/example'
319
300
 
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
301
+ module RSpec
302
+ module Core
303
+ class Example
304
+ class << self
305
+ def wrap_example_block(example, fn)
306
+ proc do
307
+ AppMap::RSpec.begin_spec example
308
+ begin
309
+ instance_exec(&fn)
310
+ ensure
311
+ AppMap::RSpec.end_spec example
312
+ end
325
313
  end
326
314
  end
327
315
  end
328
- end
329
316
 
330
- def enabled?
331
- ENV['APPMAP'] == 'true'
332
- end
333
-
334
- def run
335
- generate_inventory
336
- generate_appmaps_from_specs
317
+ def self.new(*arguments, &block)
318
+ warn "Wrapping example_block for #{name}" if AppMap::RSpec::LOG
319
+ allocate.tap do |obj|
320
+ arguments[arguments.length - 1] = wrap_example_block(obj, arguments.last) if arguments.last.is_a?(Proc)
321
+ obj.send :initialize, *arguments, &block
322
+ end
323
+ end
337
324
  end
338
325
  end
339
326
  end
340
- end
341
327
 
342
- AppMap::RSpec.run if AppMap::RSpec.enabled?
328
+ AppMap::RSpec.run
329
+ end