appmap 0.45.1 → 0.46.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.
- checksums.yaml +4 -4
- data/.travis.yml +10 -0
- data/CHANGELOG.md +7 -0
- data/README.md +10 -11
- data/lib/appmap.rb +0 -1
- data/lib/appmap/class_map.rb +7 -15
- data/lib/appmap/config.rb +15 -8
- data/lib/appmap/event.rb +29 -28
- data/lib/appmap/handler/function.rb +1 -1
- data/lib/appmap/handler/rails/request_handler.rb +124 -0
- data/lib/appmap/handler/rails/sql_handler.rb +152 -0
- data/lib/appmap/handler/rails/template.rb +149 -0
- data/lib/appmap/hook/method.rb +1 -1
- data/lib/appmap/railtie.rb +5 -5
- data/lib/appmap/trace.rb +46 -6
- data/lib/appmap/version.rb +1 -1
- data/package-lock.json +3 -3
- data/spec/abstract_controller_base_spec.rb +65 -6
- data/spec/class_map_spec.rb +3 -3
- data/spec/hook_spec.rb +2 -2
- metadata +5 -4
- data/lib/appmap/rails/request_handler.rb +0 -122
- data/lib/appmap/rails/sql_handler.rb +0 -150
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'appmap/event'
|
4
|
+
|
5
|
+
module AppMap
|
6
|
+
module Handler
|
7
|
+
module Rails
|
8
|
+
class SQLHandler
|
9
|
+
class SQLCall < AppMap::Event::MethodCall
|
10
|
+
attr_accessor :payload
|
11
|
+
|
12
|
+
def initialize(payload)
|
13
|
+
super AppMap::Event.next_id_counter, :call, Thread.current.object_id
|
14
|
+
|
15
|
+
self.payload = payload
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_h
|
19
|
+
super.tap do |h|
|
20
|
+
h[:sql_query] = {
|
21
|
+
sql: payload[:sql],
|
22
|
+
database_type: payload[:database_type]
|
23
|
+
}.tap do |sql_query|
|
24
|
+
%i[server_version].each do |attribute|
|
25
|
+
sql_query[attribute] = payload[attribute] if payload[attribute]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class SQLReturn < AppMap::Event::MethodReturnIgnoreValue
|
33
|
+
def initialize(parent_id, elapsed)
|
34
|
+
super AppMap::Event.next_id_counter, :return, Thread.current.object_id
|
35
|
+
|
36
|
+
self.parent_id = parent_id
|
37
|
+
self.elapsed = elapsed
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module SQLExaminer
|
42
|
+
class << self
|
43
|
+
def examine(payload, sql:)
|
44
|
+
return unless (examiner = build_examiner)
|
45
|
+
|
46
|
+
payload[:server_version] = examiner.server_version
|
47
|
+
payload[:database_type] = examiner.database_type.to_s
|
48
|
+
end
|
49
|
+
|
50
|
+
protected
|
51
|
+
|
52
|
+
def build_examiner
|
53
|
+
if defined?(Sequel)
|
54
|
+
SequelExaminer.new
|
55
|
+
elsif defined?(ActiveRecord)
|
56
|
+
ActiveRecordExaminer.new
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class SequelExaminer
|
62
|
+
def server_version
|
63
|
+
Sequel::Model.db.server_version
|
64
|
+
end
|
65
|
+
|
66
|
+
def database_type
|
67
|
+
Sequel::Model.db.database_type.to_sym
|
68
|
+
end
|
69
|
+
|
70
|
+
def execute_query(sql)
|
71
|
+
Sequel::Model.db[sql].all
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class ActiveRecordExaminer
|
76
|
+
@@db_version_warning_issued = {}
|
77
|
+
|
78
|
+
def issue_warning
|
79
|
+
db_type = database_type
|
80
|
+
return if @@db_version_warning_issued[db_type]
|
81
|
+
warn("AppMap: Unable to determine database version for #{db_type.inspect}")
|
82
|
+
@@db_version_warning_issued[db_type] = true
|
83
|
+
end
|
84
|
+
|
85
|
+
def server_version
|
86
|
+
ActiveRecord::Base.connection.try(:database_version) || issue_warning
|
87
|
+
end
|
88
|
+
|
89
|
+
def database_type
|
90
|
+
type = ActiveRecord::Base.connection.adapter_name.downcase.to_sym
|
91
|
+
type = :postgres if type == :postgresql
|
92
|
+
|
93
|
+
type
|
94
|
+
end
|
95
|
+
|
96
|
+
def execute_query(sql)
|
97
|
+
ActiveRecord::Base.connection.execute(sql).inject([]) { |memo, r| memo << r; memo }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def call(_, started, finished, _, payload) # (name, started, finished, unique_id, payload)
|
103
|
+
return if AppMap.tracing.empty?
|
104
|
+
|
105
|
+
reentry_key = "#{self.class.name}#call"
|
106
|
+
return if Thread.current[reentry_key] == true
|
107
|
+
|
108
|
+
Thread.current[reentry_key] = true
|
109
|
+
begin
|
110
|
+
sql = payload[:sql].strip
|
111
|
+
|
112
|
+
# Detect whether a function call within a specified filename is present in the call stack.
|
113
|
+
find_in_backtrace = lambda do |file_name, function_name = nil|
|
114
|
+
Thread.current.backtrace.find do |line|
|
115
|
+
tokens = line.split(':')
|
116
|
+
matches_file = tokens.find { |t| t.rindex(file_name) == (t.length - file_name.length) }
|
117
|
+
matches_function = function_name.nil? || tokens.find { |t| t == "in `#{function_name}'" }
|
118
|
+
matches_file && matches_function
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Ignore SQL calls which are made while establishing a new connection.
|
123
|
+
#
|
124
|
+
# Example:
|
125
|
+
# /path/to/ruby/2.6.0/gems/sequel-5.20.0/lib/sequel/connection_pool.rb:122:in `make_new'
|
126
|
+
return if find_in_backtrace.call('lib/sequel/connection_pool.rb', 'make_new')
|
127
|
+
# lib/active_record/connection_adapters/abstract/connection_pool.rb:811:in `new_connection'
|
128
|
+
return if find_in_backtrace.call('lib/active_record/connection_adapters/abstract/connection_pool.rb', 'new_connection')
|
129
|
+
|
130
|
+
# Ignore SQL calls which are made while inspecting the DB schema.
|
131
|
+
#
|
132
|
+
# Example:
|
133
|
+
# /path/to/ruby/2.6.0/gems/sequel-5.20.0/lib/sequel/model/base.rb:812:in `get_db_schema'
|
134
|
+
return if find_in_backtrace.call('lib/sequel/model/base.rb', 'get_db_schema')
|
135
|
+
# /usr/local/bundle/gems/activerecord-5.2.3/lib/active_record/model_schema.rb:466:in `load_schema!'
|
136
|
+
return if find_in_backtrace.call('lib/active_record/model_schema.rb', 'load_schema!')
|
137
|
+
return if find_in_backtrace.call('lib/active_model/attribute_methods.rb', 'define_attribute_methods')
|
138
|
+
return if find_in_backtrace.call('lib/active_record/connection_adapters/schema_cache.rb')
|
139
|
+
|
140
|
+
SQLExaminer.examine payload, sql: sql
|
141
|
+
|
142
|
+
call = SQLCall.new(payload)
|
143
|
+
AppMap.tracing.record_event(call)
|
144
|
+
AppMap.tracing.record_event(SQLReturn.new(call.id, finished - started))
|
145
|
+
ensure
|
146
|
+
Thread.current[reentry_key] = nil
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'appmap/event'
|
4
|
+
|
5
|
+
module AppMap
|
6
|
+
module Handler
|
7
|
+
module Rails
|
8
|
+
class Template
|
9
|
+
LOG = (ENV['APPMAP_TEMPLATE_DEBUG'] == 'true' || ENV['DEBUG'] == 'true')
|
10
|
+
|
11
|
+
# All the code which is touched by the AppMap is recorded in the classMap.
|
12
|
+
# This duck-typed 'method' is used to represent a view template as a package,
|
13
|
+
# class, and method in the classMap.
|
14
|
+
# The class name is generated from the template path. The package name is
|
15
|
+
# 'app/views', and the method name is 'render'. The source location of the method
|
16
|
+
# is, of course, the path to the view template.
|
17
|
+
TemplateMethod = Struct.new(:path) do
|
18
|
+
private_instance_methods :path
|
19
|
+
attr_reader :class_name
|
20
|
+
|
21
|
+
def initialize(path)
|
22
|
+
super
|
23
|
+
|
24
|
+
@class_name = path.parameterize.underscore
|
25
|
+
end
|
26
|
+
|
27
|
+
def package
|
28
|
+
'app/views'
|
29
|
+
end
|
30
|
+
|
31
|
+
def name
|
32
|
+
'render'
|
33
|
+
end
|
34
|
+
|
35
|
+
def source_location
|
36
|
+
path
|
37
|
+
end
|
38
|
+
|
39
|
+
def static
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
def comment
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
|
47
|
+
def labels
|
48
|
+
[ 'mvc.template' ]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# TemplateCall is a type of function call which is specialized to view template rendering. Since
|
53
|
+
# there isn't really a perfect method in Rails to hook, this one is synthesized from the available
|
54
|
+
# information.
|
55
|
+
class TemplateCall < AppMap::Event::MethodEvent
|
56
|
+
# This is basically the +self+ parameter.
|
57
|
+
attr_reader :render_instance
|
58
|
+
# Path to the view template.
|
59
|
+
attr_accessor :path
|
60
|
+
|
61
|
+
def initialize(render_instance)
|
62
|
+
super :call
|
63
|
+
|
64
|
+
AppMap::Event::MethodEvent.build_from_invocation(:call, event: self)
|
65
|
+
@render_instance = render_instance
|
66
|
+
end
|
67
|
+
|
68
|
+
def static?
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
def to_h
|
73
|
+
super.tap do |h|
|
74
|
+
h[:defined_class] = path ? path.parameterize.underscore : 'inline_template'
|
75
|
+
h[:method_id] = 'render'
|
76
|
+
h[:path] = path
|
77
|
+
h[:static] = static?
|
78
|
+
h[:parameters] = []
|
79
|
+
h[:receiver] = {
|
80
|
+
class: AppMap::Event::MethodEvent.best_class_name(render_instance),
|
81
|
+
object_id: render_instance.__id__,
|
82
|
+
value: AppMap::Event::MethodEvent.display_string(render_instance)
|
83
|
+
}
|
84
|
+
h.compact
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
TEMPLATE_RENDERER = 'appmap.handler.rails.template.renderer'
|
90
|
+
|
91
|
+
# Hooks the ActionView::Resolver methods +find_all+, +find_all_anywhere+. The resolver is used
|
92
|
+
# during template rendering to lookup the template file path from parameters such as the
|
93
|
+
# template name, prefix, and partial (boolean).
|
94
|
+
class ResolverHandler
|
95
|
+
class << self
|
96
|
+
# Handled as a normal function call.
|
97
|
+
def handle_call(defined_class, hook_method, receiver, args)
|
98
|
+
name, prefix, partial = args
|
99
|
+
warn "Resolver: #{{ name: name, prefix: prefix, partial: partial }}" if LOG
|
100
|
+
|
101
|
+
AppMap::Handler::Function.handle_call(defined_class, hook_method, receiver, args)
|
102
|
+
end
|
103
|
+
|
104
|
+
# When the resolver returns, look to see if there is template rendering underway.
|
105
|
+
# If so, populate the template path. In all cases, add a TemplateMethod so that the
|
106
|
+
# template will be recorded in the classMap.
|
107
|
+
def handle_return(call_event_id, elapsed, return_value, exception)
|
108
|
+
warn "Resolver return: #{return_value.inspect}" if LOG
|
109
|
+
|
110
|
+
renderer = Array(Thread.current[TEMPLATE_RENDERER]).last
|
111
|
+
path = Array(return_value).first&.inspect
|
112
|
+
|
113
|
+
if path
|
114
|
+
AppMap.tracing.record_method(TemplateMethod.new(path))
|
115
|
+
renderer.path ||= path if renderer
|
116
|
+
end
|
117
|
+
|
118
|
+
AppMap::Handler::Function.handle_return(call_event_id, elapsed, return_value, exception)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
# Hooks the ActionView::Renderer method +render+. This method is used by Rails to perform
|
124
|
+
# template rendering. The TemplateCall event which is emitted by this handler has a
|
125
|
+
# +path+ parameter, which is nil until it's filled in by a ResolverHandler.
|
126
|
+
class RenderHandler
|
127
|
+
class << self
|
128
|
+
def handle_call(defined_class, hook_method, receiver, args)
|
129
|
+
context, options = args
|
130
|
+
|
131
|
+
warn "Renderer: #{options}" if LOG
|
132
|
+
|
133
|
+
TemplateCall.new(receiver).tap do |call|
|
134
|
+
Thread.current[TEMPLATE_RENDERER] ||= []
|
135
|
+
Thread.current[TEMPLATE_RENDERER] << call
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_return(call_event_id, elapsed, return_value, exception)
|
140
|
+
Array(Thread.current[TEMPLATE_RENDERER]).pop
|
141
|
+
|
142
|
+
AppMap::Event::MethodReturnIgnoreValue.build_from_invocation(call_event_id, elapsed: elapsed)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
data/lib/appmap/hook/method.rb
CHANGED
@@ -95,7 +95,7 @@ module AppMap
|
|
95
95
|
def after_hook(_receiver, call_event, start_time, return_value, exception)
|
96
96
|
elapsed = TIME_NOW.call - start_time
|
97
97
|
return_event = hook_package.handler_class.handle_return(call_event.id, elapsed, return_value, exception)
|
98
|
-
AppMap.tracing.record_event return_event
|
98
|
+
AppMap.tracing.record_event(return_event) if return_event
|
99
99
|
nil
|
100
100
|
end
|
101
101
|
|
data/lib/appmap/railtie.rb
CHANGED
@@ -8,12 +8,12 @@ module AppMap
|
|
8
8
|
# appmap.subscribe subscribes to ActiveSupport Notifications so that they can be recorded as
|
9
9
|
# AppMap events.
|
10
10
|
initializer 'appmap.subscribe' do |_| # params: app
|
11
|
-
require 'appmap/rails/sql_handler'
|
12
|
-
require 'appmap/rails/request_handler'
|
13
|
-
ActiveSupport::Notifications.subscribe 'sql.sequel', AppMap::Rails::SQLHandler.new
|
14
|
-
ActiveSupport::Notifications.subscribe 'sql.active_record', AppMap::Rails::SQLHandler.new
|
11
|
+
require 'appmap/handler/rails/sql_handler'
|
12
|
+
require 'appmap/handler/rails/request_handler'
|
13
|
+
ActiveSupport::Notifications.subscribe 'sql.sequel', AppMap::Handler::Rails::SQLHandler.new
|
14
|
+
ActiveSupport::Notifications.subscribe 'sql.active_record', AppMap::Handler::Rails::SQLHandler.new
|
15
15
|
|
16
|
-
AppMap::Rails::RequestHandler::HookMethod.new.activate
|
16
|
+
AppMap::Handler::Rails::RequestHandler::HookMethod.new.activate
|
17
17
|
end
|
18
18
|
|
19
19
|
# appmap.trace begins recording an AppMap trace and writes it to appmap.json.
|
data/lib/appmap/trace.rb
CHANGED
@@ -2,14 +2,36 @@
|
|
2
2
|
|
3
3
|
module AppMap
|
4
4
|
module Trace
|
5
|
-
class
|
6
|
-
attr_reader :
|
5
|
+
class RubyMethod
|
6
|
+
attr_reader :class_name, :static
|
7
7
|
|
8
|
-
def initialize(package,
|
8
|
+
def initialize(package, class_name, method, static)
|
9
9
|
@package = package
|
10
|
-
@
|
10
|
+
@class_name = class_name
|
11
|
+
@method = method
|
11
12
|
@static = static
|
12
|
-
|
13
|
+
end
|
14
|
+
|
15
|
+
def source_location
|
16
|
+
@method.source_location
|
17
|
+
end
|
18
|
+
|
19
|
+
def comment
|
20
|
+
@method.comment
|
21
|
+
rescue MethodSource::SourceNotFoundError
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
|
25
|
+
def package
|
26
|
+
@package.name
|
27
|
+
end
|
28
|
+
|
29
|
+
def name
|
30
|
+
@method.name
|
31
|
+
end
|
32
|
+
|
33
|
+
def labels
|
34
|
+
@package.labels
|
13
35
|
end
|
14
36
|
end
|
15
37
|
|
@@ -43,6 +65,12 @@ module AppMap
|
|
43
65
|
end
|
44
66
|
end
|
45
67
|
|
68
|
+
def record_method(method)
|
69
|
+
@tracers.each do |tracer|
|
70
|
+
tracer.record_method(method)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
46
74
|
def delete(tracer)
|
47
75
|
return unless @tracers.member?(tracer)
|
48
76
|
|
@@ -83,10 +111,22 @@ module AppMap
|
|
83
111
|
@last_package_for_thread[Thread.current.object_id] = package if package
|
84
112
|
@events << event
|
85
113
|
static = event.static if event.respond_to?(:static)
|
86
|
-
@methods << Trace::
|
114
|
+
@methods << Trace::RubyMethod.new(package, defined_class, method, static) \
|
87
115
|
if package && defined_class && method && (event.event == :call)
|
88
116
|
end
|
89
117
|
|
118
|
+
# +method+ should be duck-typed to respond to the following:
|
119
|
+
# * package
|
120
|
+
# * defined_class
|
121
|
+
# * name
|
122
|
+
# * static
|
123
|
+
# * comment
|
124
|
+
# * labels
|
125
|
+
# * source_location
|
126
|
+
def record_method(method)
|
127
|
+
@methods << method
|
128
|
+
end
|
129
|
+
|
90
130
|
# Gets the last package which was observed on the current thread.
|
91
131
|
def last_package_for_current_thread
|
92
132
|
@last_package_for_thread[Thread.current.object_id]
|
data/lib/appmap/version.rb
CHANGED
data/package-lock.json
CHANGED
@@ -551,9 +551,9 @@
|
|
551
551
|
}
|
552
552
|
},
|
553
553
|
"lodash": {
|
554
|
-
"version": "4.17.
|
555
|
-
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.
|
556
|
-
"integrity": "sha512-
|
554
|
+
"version": "4.17.21",
|
555
|
+
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
556
|
+
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
557
557
|
},
|
558
558
|
"longest": {
|
559
559
|
"version": "1.0.1",
|
@@ -144,7 +144,49 @@ describe 'Rails' do
|
|
144
144
|
end
|
145
145
|
|
146
146
|
describe 'a UI route' do
|
147
|
-
describe 'rendering a page' do
|
147
|
+
describe 'rendering a page using a template file' do
|
148
|
+
let(:appmap_json_file) do
|
149
|
+
'UsersController_GET_users_lists_the_users.appmap.json'
|
150
|
+
end
|
151
|
+
|
152
|
+
it 'records the template file' do
|
153
|
+
expect(events).to include hash_including(
|
154
|
+
'event' => 'call',
|
155
|
+
'defined_class' => 'app_views_users_index_html_haml',
|
156
|
+
'method_id' => 'render',
|
157
|
+
'path' => 'app/views/users/index.html.haml'
|
158
|
+
)
|
159
|
+
|
160
|
+
expect(appmap['classMap']).to include hash_including(
|
161
|
+
'name' => 'app/views',
|
162
|
+
'children' => include(hash_including(
|
163
|
+
'name' => 'app_views_users_index_html_haml',
|
164
|
+
'children' => include(hash_including(
|
165
|
+
'name' => 'render',
|
166
|
+
'type' => 'function',
|
167
|
+
'location' => 'app/views/users/index.html.haml',
|
168
|
+
'static' => true,
|
169
|
+
'labels' => [ 'mvc.template' ]
|
170
|
+
))
|
171
|
+
))
|
172
|
+
)
|
173
|
+
expect(appmap['classMap']).to include hash_including(
|
174
|
+
'name' => 'app/views',
|
175
|
+
'children' => include(hash_including(
|
176
|
+
'name' => 'app_views_layouts_application_html_haml',
|
177
|
+
'children' => include(hash_including(
|
178
|
+
'name' => 'render',
|
179
|
+
'type' => 'function',
|
180
|
+
'location' => 'app/views/layouts/application.html.haml',
|
181
|
+
'static' => true,
|
182
|
+
'labels' => [ 'mvc.template' ]
|
183
|
+
))
|
184
|
+
))
|
185
|
+
)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
describe 'rendering a page using a text template' do
|
148
190
|
let(:appmap_json_file) do
|
149
191
|
'UsersController_GET_users_login_shows_the_user.appmap.json'
|
150
192
|
end
|
@@ -165,15 +207,32 @@ describe 'Rails' do
|
|
165
207
|
)
|
166
208
|
end
|
167
209
|
|
210
|
+
it 'ignores the text template' do
|
211
|
+
expect(events).to_not include hash_including(
|
212
|
+
'event' => 'call',
|
213
|
+
'method_id' => 'render',
|
214
|
+
'render_template' => anything
|
215
|
+
)
|
216
|
+
|
217
|
+
expect(appmap['classMap']).to_not include hash_including(
|
218
|
+
'name' => 'views',
|
219
|
+
'children' => include(hash_including(
|
220
|
+
'name' => 'ViewTemplate',
|
221
|
+
'children' => include(hash_including(
|
222
|
+
'name' => 'render',
|
223
|
+
'type' => 'function',
|
224
|
+
'location' => 'text template'
|
225
|
+
))
|
226
|
+
))
|
227
|
+
)
|
228
|
+
end
|
229
|
+
|
168
230
|
it 'records and labels view rendering' do
|
169
231
|
expect(events).to include hash_including(
|
170
232
|
'event' => 'call',
|
171
233
|
'thread_id' => Numeric,
|
172
|
-
'defined_class' => '
|
173
|
-
'method_id' => 'render'
|
174
|
-
'path' => String,
|
175
|
-
'lineno' => Integer,
|
176
|
-
'static' => false
|
234
|
+
'defined_class' => 'inline_template',
|
235
|
+
'method_id' => 'render'
|
177
236
|
)
|
178
237
|
|
179
238
|
expect(appmap['classMap']).to include hash_including(
|