appmap 0.23.0 → 0.27.0

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