activesupport 8.0.3 → 8.1.0.beta1

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +237 -175
  3. data/lib/active_support/backtrace_cleaner.rb +71 -0
  4. data/lib/active_support/cache/mem_cache_store.rb +13 -13
  5. data/lib/active_support/cache/redis_cache_store.rb +36 -30
  6. data/lib/active_support/cache/strategy/local_cache.rb +16 -7
  7. data/lib/active_support/cache/strategy/local_cache_middleware.rb +7 -7
  8. data/lib/active_support/cache.rb +69 -6
  9. data/lib/active_support/configurable.rb +28 -0
  10. data/lib/active_support/continuous_integration.rb +145 -0
  11. data/lib/active_support/core_ext/benchmark.rb +0 -1
  12. data/lib/active_support/core_ext/date_and_time/compatibility.rb +1 -1
  13. data/lib/active_support/core_ext/erb/util.rb +3 -3
  14. data/lib/active_support/core_ext/object/json.rb +8 -1
  15. data/lib/active_support/core_ext/object/to_query.rb +5 -0
  16. data/lib/active_support/core_ext/range.rb +0 -1
  17. data/lib/active_support/core_ext/string/multibyte.rb +10 -1
  18. data/lib/active_support/core_ext/string/output_safety.rb +19 -12
  19. data/lib/active_support/current_attributes/test_helper.rb +2 -2
  20. data/lib/active_support/current_attributes.rb +13 -10
  21. data/lib/active_support/deprecation/reporting.rb +4 -2
  22. data/lib/active_support/deprecation.rb +1 -1
  23. data/lib/active_support/editor.rb +70 -0
  24. data/lib/active_support/error_reporter.rb +50 -6
  25. data/lib/active_support/event_reporter/test_helper.rb +32 -0
  26. data/lib/active_support/event_reporter.rb +570 -0
  27. data/lib/active_support/evented_file_update_checker.rb +5 -1
  28. data/lib/active_support/execution_context.rb +64 -7
  29. data/lib/active_support/file_update_checker.rb +8 -6
  30. data/lib/active_support/gem_version.rb +3 -3
  31. data/lib/active_support/gzip.rb +1 -0
  32. data/lib/active_support/hash_with_indifferent_access.rb +27 -7
  33. data/lib/active_support/i18n_railtie.rb +1 -2
  34. data/lib/active_support/inflector/inflections.rb +31 -15
  35. data/lib/active_support/inflector/transliterate.rb +6 -8
  36. data/lib/active_support/isolated_execution_state.rb +7 -13
  37. data/lib/active_support/json/decoding.rb +2 -2
  38. data/lib/active_support/json/encoding.rb +103 -14
  39. data/lib/active_support/log_subscriber.rb +2 -0
  40. data/lib/active_support/message_encryptors.rb +52 -0
  41. data/lib/active_support/message_pack/extensions.rb +5 -0
  42. data/lib/active_support/message_verifiers.rb +52 -0
  43. data/lib/active_support/messages/rotation_coordinator.rb +9 -0
  44. data/lib/active_support/messages/rotator.rb +5 -0
  45. data/lib/active_support/multibyte/chars.rb +8 -1
  46. data/lib/active_support/multibyte.rb +4 -0
  47. data/lib/active_support/railtie.rb +26 -12
  48. data/lib/active_support/syntax_error_proxy.rb +3 -0
  49. data/lib/active_support/test_case.rb +61 -6
  50. data/lib/active_support/testing/assertions.rb +34 -6
  51. data/lib/active_support/testing/error_reporter_assertions.rb +18 -1
  52. data/lib/active_support/testing/event_reporter_assertions.rb +217 -0
  53. data/lib/active_support/testing/notification_assertions.rb +92 -0
  54. data/lib/active_support/testing/parallelization/worker.rb +2 -0
  55. data/lib/active_support/testing/parallelization.rb +13 -0
  56. data/lib/active_support/testing/tests_without_assertions.rb +1 -1
  57. data/lib/active_support/testing/time_helpers.rb +7 -3
  58. data/lib/active_support/time_with_zone.rb +19 -5
  59. data/lib/active_support/values/time_zone.rb +8 -1
  60. data/lib/active_support/xml_mini.rb +1 -2
  61. data/lib/active_support.rb +11 -0
  62. metadata +10 -5
  63. data/lib/active_support/core_ext/range/each.rb +0 -24
@@ -26,6 +26,9 @@ module ActiveSupport
26
26
  # as the first rotation and <tt>transitional = true</tt>. Then, after all
27
27
  # servers have been updated, perform a second rolling deploy with
28
28
  # <tt>transitional = false</tt>.
29
+ #
30
+ #--
31
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#transitional
29
32
 
30
33
  ##
31
34
  # :singleton-method: new
@@ -42,6 +45,9 @@ module ActiveSupport
42
45
  # end
43
46
  #
44
47
  # verifiers.rotate(base: "...")
48
+ #
49
+ #--
50
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#initialize
45
51
 
46
52
  ##
47
53
  # :method: []
@@ -50,12 +56,18 @@ module ActiveSupport
50
56
  # Returns a MessageVerifier configured with a secret derived from the
51
57
  # given +salt+, and options from #rotate. MessageVerifier instances will
52
58
  # be memoized, so the same +salt+ will return the same instance.
59
+ #
60
+ #--
61
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#[]
53
62
 
54
63
  ##
55
64
  # :method: []=
56
65
  # :call-seq: []=(salt, verifier)
57
66
  #
58
67
  # Overrides a MessageVerifier instance associated with a given +salt+.
68
+ #
69
+ #--
70
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#[]=
59
71
 
60
72
  ##
61
73
  # :method: rotate
@@ -104,18 +116,55 @@ module ActiveSupport
104
116
  #
105
117
  # # Uses `serializer: Marshal, url_safe: false`.
106
118
  # verifiers[:baz]
119
+ #
120
+ #--
121
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#rotate
122
+
123
+ ##
124
+ # :method: prepend
125
+ # :call-seq:
126
+ # prepend(**options)
127
+ # prepend(&block)
128
+ #
129
+ # Just like #rotate, but prepends the given options or block to the list of
130
+ # option sets.
131
+ #
132
+ # This can be useful when you have an already-configured +MessageVerifiers+
133
+ # instance, but you want to override the way messages are signed.
134
+ #
135
+ # module ThirdParty
136
+ # VERIFIERS = ActiveSupport::MessageVerifiers.new { ... }.
137
+ # rotate(serializer: Marshal, url_safe: true).
138
+ # rotate(serializer: Marshal, url_safe: false)
139
+ # end
140
+ #
141
+ # ThirdParty.VERIFIERS.prepend(serializer: JSON, url_safe: true)
142
+ #
143
+ # # Uses `serializer: JSON, url_safe: true`.
144
+ # # Falls back to `serializer: Marshal, url_safe: true` or
145
+ # # `serializer: Marshal, url_safe: false`.
146
+ # ThirdParty.VERIFIERS[:foo]
147
+ #
148
+ #--
149
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#prepend
107
150
 
108
151
  ##
109
152
  # :method: rotate_defaults
110
153
  # :call-seq: rotate_defaults
111
154
  #
112
155
  # Invokes #rotate with the default options.
156
+ #
157
+ #--
158
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#rotate_defaults
113
159
 
114
160
  ##
115
161
  # :method: clear_rotations
116
162
  # :call-seq: clear_rotations
117
163
  #
118
164
  # Clears the list of option sets.
165
+ #
166
+ #--
167
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#clear_rotations
119
168
 
120
169
  ##
121
170
  # :method: on_rotation
@@ -127,6 +176,9 @@ module ActiveSupport
127
176
  # For example, this callback could log each time it is called, and thus
128
177
  # indicate whether old option sets are still in use or can be removed from
129
178
  # rotation.
179
+ #
180
+ #--
181
+ # Implemented by ActiveSupport::Messages::RotationCoordinator#on_rotation
130
182
 
131
183
  ##
132
184
  private
@@ -32,6 +32,15 @@ module ActiveSupport
32
32
  self
33
33
  end
34
34
 
35
+ def prepend(**options, &block)
36
+ raise ArgumentError, "Options cannot be specified when using a block" if block && !options.empty?
37
+ changing_configuration!
38
+
39
+ @rotate_options.unshift(block || options)
40
+
41
+ self
42
+ end
43
+
35
44
  def rotate_defaults
36
45
  rotate()
37
46
  end
@@ -45,6 +45,11 @@ module ActiveSupport
45
45
  end
46
46
  end
47
47
 
48
+ def initialize_dup(*)
49
+ super
50
+ @rotations = @rotations.dup
51
+ end
52
+
48
53
  private
49
54
  def build_rotation(*args, **options)
50
55
  self.class.new(*args, *@args.drop(args.length), **@options, **options)
@@ -53,7 +53,14 @@ module ActiveSupport # :nodoc:
53
53
  delegate :<=>, :=~, :match?, :acts_like_string?, to: :wrapped_string
54
54
 
55
55
  # Creates a new Chars instance by wrapping _string_.
56
- def initialize(string)
56
+ def initialize(string, deprecation: true)
57
+ if deprecation
58
+ ActiveSupport.deprecator.warn(
59
+ "ActiveSupport::Multibyte::Chars is deprecated and will be removed in Rails 8.2. " \
60
+ "Use normal string methods instead."
61
+ )
62
+ end
63
+
57
64
  @wrapped_string = string
58
65
  if string.encoding != Encoding::UTF_8
59
66
  @wrapped_string = @wrapped_string.dup
@@ -12,6 +12,10 @@ module ActiveSupport # :nodoc:
12
12
  #
13
13
  # ActiveSupport::Multibyte.proxy_class = CharsForUTF32
14
14
  def self.proxy_class=(klass)
15
+ ActiveSupport.deprecator.warn(
16
+ "ActiveSupport::Multibyte.proxy_class= is deprecated and will be removed in Rails 8.2. " \
17
+ "Use normal string methods instead."
18
+ )
15
19
  @proxy_class = klass
16
20
  end
17
21
 
@@ -15,7 +15,7 @@ module ActiveSupport
15
15
 
16
16
  initializer "active_support.isolation_level" do |app|
17
17
  config.after_initialize do
18
- if level = app.config.active_support.delete(:isolation_level)
18
+ if level = app.config.active_support.isolation_level
19
19
  ActiveSupport::IsolatedExecutionState.isolation_level = level
20
20
  end
21
21
  end
@@ -38,19 +38,35 @@ module ActiveSupport
38
38
  end
39
39
  end
40
40
 
41
- initializer "active_support.reset_execution_context" do |app|
42
- app.reloader.before_class_unload { ActiveSupport::ExecutionContext.clear }
43
- app.executor.to_run { ActiveSupport::ExecutionContext.clear }
44
- app.executor.to_complete { ActiveSupport::ExecutionContext.clear }
41
+ initializer "active_support.set_event_reporter_context_store" do |app|
42
+ config.after_initialize do
43
+ if klass = app.config.active_support.event_reporter_context_store
44
+ ActiveSupport::EventReporter.context_store = klass
45
+ end
46
+ end
45
47
  end
46
48
 
47
- initializer "active_support.reset_all_current_attributes_instances" do |app|
48
- app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all }
49
- app.executor.to_run { ActiveSupport::CurrentAttributes.reset_all }
50
- app.executor.to_complete { ActiveSupport::CurrentAttributes.reset_all }
49
+ initializer "active_support.reset_execution_context" do |app|
50
+ app.reloader.before_class_unload do
51
+ ActiveSupport::CurrentAttributes.clear_all
52
+ ActiveSupport::ExecutionContext.clear
53
+ ActiveSupport.event_reporter.clear_context
54
+ end
55
+
56
+ app.executor.to_run do
57
+ ActiveSupport::ExecutionContext.push
58
+ end
59
+
60
+ app.executor.to_complete do
61
+ ActiveSupport::CurrentAttributes.clear_all
62
+ ActiveSupport::ExecutionContext.pop
63
+ ActiveSupport.event_reporter.clear_context
64
+ end
51
65
 
52
66
  ActiveSupport.on_load(:active_support_test_case) do
53
67
  if app.config.active_support.executor_around_test_case
68
+ ActiveSupport::ExecutionContext.nestable = true
69
+
54
70
  require "active_support/executor/test_helper"
55
71
  include ActiveSupport::Executor::TestHelper
56
72
  else
@@ -97,9 +113,7 @@ module ActiveSupport
97
113
  end
98
114
 
99
115
  initializer "active_support.to_time_preserves_timezone" do |app|
100
- config.after_initialize do
101
- ActiveSupport.to_time_preserves_timezone = app.config.active_support.to_time_preserves_timezone
102
- end
116
+ ActiveSupport.to_time_preserves_timezone = app.config.active_support.to_time_preserves_timezone
103
117
  end
104
118
 
105
119
  # Sets the default week start
@@ -18,6 +18,9 @@ module ActiveSupport
18
18
 
19
19
  def label
20
20
  end
21
+
22
+ def base_label
23
+ end
21
24
  end
22
25
 
23
26
  class BacktraceLocationProxy < DelegateClass(Thread::Backtrace::Location) # :nodoc:
@@ -6,6 +6,7 @@ require "active_support/testing/setup_and_teardown"
6
6
  require "active_support/testing/tests_without_assertions"
7
7
  require "active_support/testing/assertions"
8
8
  require "active_support/testing/error_reporter_assertions"
9
+ require "active_support/testing/event_reporter_assertions"
9
10
  require "active_support/testing/deprecation"
10
11
  require "active_support/testing/declarative"
11
12
  require "active_support/testing/isolation"
@@ -15,13 +16,29 @@ require "active_support/testing/constant_stubbing"
15
16
  require "active_support/testing/file_fixtures"
16
17
  require "active_support/testing/parallelization"
17
18
  require "active_support/testing/parallelize_executor"
19
+ require "active_support/testing/notification_assertions"
18
20
  require "concurrent/utility/processor_counter"
19
21
 
20
22
  module ActiveSupport
21
23
  class TestCase < ::Minitest::Test
22
24
  Assertion = Minitest::Assertion
23
25
 
26
+ # Class variable to store the parallel worker ID
27
+ @@parallel_worker_id = nil
28
+
24
29
  class << self
30
+ # Returns the current parallel worker ID if tests are running in parallel,
31
+ # nil otherwise.
32
+ #
33
+ # ActiveSupport::TestCase.parallel_worker_id # => 2
34
+ def parallel_worker_id
35
+ @@parallel_worker_id
36
+ end
37
+
38
+ def parallel_worker_id=(value) # :nodoc:
39
+ @@parallel_worker_id = value
40
+ end
41
+
25
42
  # Sets the order in which test cases are run.
26
43
  #
27
44
  # ActiveSupport::TestCase.test_order = :random # => :random
@@ -51,8 +68,8 @@ module ActiveSupport
51
68
  # is forked. For each process a new database will be created suffixed
52
69
  # with the worker number.
53
70
  #
54
- # test-database-0
55
- # test-database-1
71
+ # test-database_0
72
+ # test-database_1
56
73
  #
57
74
  # If <tt>ENV["PARALLEL_WORKERS"]</tt> is set the workers argument will be ignored
58
75
  # and the environment variable will be used instead. This is useful for CI
@@ -78,14 +95,45 @@ module ActiveSupport
78
95
  # Because parallelization presents an overhead, it is only enabled when the
79
96
  # number of tests to run is above the +threshold+ param. The default value is
80
97
  # 50, and it's configurable via +config.active_support.test_parallelization_threshold+.
81
- def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold)
82
- workers = Concurrent.processor_count if workers == :number_of_processors
83
- workers = ENV["PARALLEL_WORKERS"].to_i if ENV["PARALLEL_WORKERS"]
98
+ #
99
+ # If you want to skip Rails default creation of one database per process in favor of
100
+ # writing your own implementation, you can set +parallelize_databases+, or configure it
101
+ # via +config.active_support.parallelize_test_databases+.
102
+ #
103
+ # parallelize(workers: :number_of_processors, parallelize_databases: false)
104
+ #
105
+ # Note that your test suite may deadlock if you attempt to use only one database
106
+ # with multiple processes.
107
+ def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold, parallelize_databases: ActiveSupport.parallelize_test_databases)
108
+ case
109
+ when ENV["PARALLEL_WORKERS"]
110
+ workers = ENV["PARALLEL_WORKERS"].to_i
111
+ when workers == :number_of_processors
112
+ workers = (Concurrent.available_processor_count || Concurrent.processor_count).floor
113
+ end
114
+
115
+ if with == :processes
116
+ ActiveSupport.parallelize_test_databases = parallelize_databases
117
+ end
84
118
 
85
119
  Minitest.parallel_executor = ActiveSupport::Testing::ParallelizeExecutor.new(size: workers, with: with, threshold: threshold)
86
120
  end
87
121
 
88
- # Set up hook for parallel testing. This can be used if you have multiple
122
+ # Before fork hook for parallel testing. This can be used to run anything
123
+ # before the processes are forked.
124
+ #
125
+ # In your +test_helper.rb+ add the following:
126
+ #
127
+ # class ActiveSupport::TestCase
128
+ # parallelize_before_fork do
129
+ # # run this before fork
130
+ # end
131
+ # end
132
+ def parallelize_before_fork(&block)
133
+ ActiveSupport::Testing::Parallelization.before_fork_hook(&block)
134
+ end
135
+
136
+ # Setup hook for parallel testing. This can be used if you have multiple
89
137
  # databases or any behavior that needs to be run after the process is forked
90
138
  # but before the tests run.
91
139
  #
@@ -141,11 +189,18 @@ module ActiveSupport
141
189
 
142
190
  alias_method :method_name, :name
143
191
 
192
+ # Returns the current parallel worker ID if tests are running in parallel
193
+ def parallel_worker_id
194
+ self.class.parallel_worker_id
195
+ end
196
+
144
197
  include ActiveSupport::Testing::TaggedLogging
145
198
  prepend ActiveSupport::Testing::SetupAndTeardown
146
199
  prepend ActiveSupport::Testing::TestsWithoutAssertions
147
200
  include ActiveSupport::Testing::Assertions
148
201
  include ActiveSupport::Testing::ErrorReporterAssertions
202
+ include ActiveSupport::Testing::EventReporterAssertions
203
+ include ActiveSupport::Testing::NotificationAssertions
149
204
  include ActiveSupport::Testing::Deprecation
150
205
  include ActiveSupport::Testing::ConstantStubbing
151
206
  include ActiveSupport::Testing::TimeHelpers
@@ -71,19 +71,19 @@ module ActiveSupport
71
71
  # post :delete, params: { id: ... }
72
72
  # end
73
73
  #
74
- # An array of expressions can also be passed in and evaluated.
74
+ # An array of expressions can be passed in and evaluated.
75
75
  #
76
76
  # assert_difference [ 'Article.count', 'Post.count' ], 2 do
77
77
  # post :create, params: { article: {...} }
78
78
  # end
79
79
  #
80
- # A hash of expressions/numeric differences can also be passed in and evaluated.
80
+ # A hash of expressions/numeric differences can be passed in and evaluated.
81
81
  #
82
- # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do
82
+ # assert_difference({ 'Article.count' => 1, 'Notification.count' => 2 }) do
83
83
  # post :create, params: { article: {...} }
84
84
  # end
85
85
  #
86
- # A lambda or a list of lambdas can be passed in and evaluated:
86
+ # A lambda, a list of lambdas or a hash of lambdas/numeric differences can be passed in and evaluated:
87
87
  #
88
88
  # assert_difference ->{ Article.count }, 2 do
89
89
  # post :create, params: { article: {...} }
@@ -93,6 +93,10 @@ module ActiveSupport
93
93
  # post :create, params: { article: {...} }
94
94
  # end
95
95
  #
96
+ # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do
97
+ # post :create, params: { article: {...} }
98
+ # end
99
+ #
96
100
  # An error message can be specified.
97
101
  #
98
102
  # assert_difference 'Article.count', -1, 'An Article should be destroyed' do
@@ -181,12 +185,24 @@ module ActiveSupport
181
185
  #
182
186
  # The keyword arguments +:from+ and +:to+ can be given to specify the
183
187
  # expected initial value and the expected value after the block was
184
- # executed.
188
+ # executed. The comparison is done using case equality (===), which means
189
+ # you can specify patterns or classes:
185
190
  #
191
+ # # Exact value match
186
192
  # assert_changes :@object, from: nil, to: :foo do
187
193
  # @object = :foo
188
194
  # end
189
195
  #
196
+ # # Case equality
197
+ # assert_changes -> { user.token }, to: /\w{32}/ do
198
+ # user.generate_token
199
+ # end
200
+ #
201
+ # # Type check
202
+ # assert_changes -> { current_error }, from: nil, to: RuntimeError do
203
+ # raise "Oops"
204
+ # end
205
+ #
190
206
  # An error message can be specified.
191
207
  #
192
208
  # assert_changes -> { Status.all_good? }, 'Expected the status to be bad' do
@@ -238,12 +254,24 @@ module ActiveSupport
238
254
  # end
239
255
  #
240
256
  # Provide the optional keyword argument +:from+ to specify the expected
241
- # initial value.
257
+ # initial value. The comparison is done using case equality (===), which means
258
+ # you can specify patterns or classes:
242
259
  #
260
+ # # Exact value match
243
261
  # assert_no_changes -> { Status.all_good? }, from: true do
244
262
  # post :create, params: { status: { ok: true } }
245
263
  # end
246
264
  #
265
+ # # Case equality
266
+ # assert_no_changes -> { user.token }, from: /\w{32}/ do
267
+ # user.touch
268
+ # end
269
+ #
270
+ # # Type check
271
+ # assert_no_changes -> { current_error }, from: RuntimeError do
272
+ # retry_operation
273
+ # end
274
+ #
247
275
  # An error message can be specified.
248
276
  #
249
277
  # assert_no_changes -> { Status.all_good? }, 'Expected the status to be good' do
@@ -44,7 +44,7 @@ module ActiveSupport
44
44
  ActiveSupport.error_reporter.subscribe(self)
45
45
  @subscribed = true
46
46
  else
47
- raise Minitest::Assertion, "No error reporter is configured"
47
+ flunk("No error reporter is configured")
48
48
  end
49
49
  end
50
50
  end
@@ -102,6 +102,23 @@ module ActiveSupport
102
102
  assert(false, message)
103
103
  end
104
104
  end
105
+
106
+ # Captures reported errors from within the block that match the given
107
+ # error class.
108
+ #
109
+ # reports = capture_error_reports(IOError) do
110
+ # Rails.error.report(IOError.new("Oops"))
111
+ # Rails.error.report(IOError.new("Oh no"))
112
+ # Rails.error.report(StandardError.new)
113
+ # end
114
+ #
115
+ # assert_equal 2, reports.size
116
+ # assert_equal "Oops", reports.first.error.message
117
+ # assert_equal "Oh no", reports.last.error.message
118
+ def capture_error_reports(error_class = StandardError, &block)
119
+ reports = ErrorCollector.record(&block)
120
+ reports.select { |r| error_class === r.error }
121
+ end
105
122
  end
106
123
  end
107
124
  end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSupport
4
+ module Testing
5
+ # Provides test helpers for asserting on ActiveSupport::EventReporter events.
6
+ module EventReporterAssertions
7
+ module EventCollector # :nodoc:
8
+ @subscribed = false
9
+ @mutex = Mutex.new
10
+
11
+ class Event # :nodoc:
12
+ attr_reader :event_data
13
+
14
+ def initialize(event_data)
15
+ @event_data = event_data
16
+ end
17
+
18
+ def inspect
19
+ "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect})"
20
+ end
21
+
22
+ def matches?(name, payload, tags)
23
+ return false unless name.to_s == event_data[:name]
24
+
25
+ if payload && payload.is_a?(Hash)
26
+ return false unless matches_hash?(payload, :payload)
27
+ end
28
+
29
+ return false unless matches_hash?(tags, :tags)
30
+ true
31
+ end
32
+
33
+ private
34
+ def matches_hash?(expected_hash, event_key)
35
+ expected_hash.all? do |k, v|
36
+ if v.is_a?(Regexp)
37
+ event_data.dig(event_key, k).to_s.match?(v)
38
+ else
39
+ event_data.dig(event_key, k) == v
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ class << self
46
+ def emit(event)
47
+ event_recorders&.each do |events|
48
+ events << Event.new(event)
49
+ end
50
+ true
51
+ end
52
+
53
+ def record
54
+ subscribe
55
+ events = []
56
+ event_recorders << events
57
+ begin
58
+ yield
59
+ events
60
+ ensure
61
+ event_recorders.delete_if { |r| events.equal?(r) }
62
+ end
63
+ end
64
+
65
+ private
66
+ def subscribe
67
+ return if @subscribed
68
+
69
+ @mutex.synchronize do
70
+ unless @subscribed
71
+ if ActiveSupport.event_reporter
72
+ ActiveSupport.event_reporter.subscribe(self)
73
+ @subscribed = true
74
+ else
75
+ raise Minitest::Assertion, "No event reporter is configured"
76
+ end
77
+ end
78
+ end
79
+ end
80
+
81
+ def event_recorders
82
+ ActiveSupport::IsolatedExecutionState[:active_support_event_reporter_assertions] ||= []
83
+ end
84
+ end
85
+ end
86
+
87
+ # Asserts that the block does not cause an event to be reported to +Rails.event+.
88
+ #
89
+ # If no name is provided, passes if evaluated code in the yielded block reports no events.
90
+ #
91
+ # assert_no_event_reported do
92
+ # service_that_does_not_report_events.perform
93
+ # end
94
+ #
95
+ # If a name is provided, passes if evaluated code in the yielded block reports no events
96
+ # with that name.
97
+ #
98
+ # assert_no_event_reported("user.created") do
99
+ # service_that_does_not_report_events.perform
100
+ # end
101
+ def assert_no_event_reported(name = nil, payload: {}, tags: {}, &block)
102
+ events = EventCollector.record(&block)
103
+
104
+ if name.nil?
105
+ assert_predicate(events, :empty?)
106
+ else
107
+ matching_event = events.find { |event| event.matches?(name, payload, tags) }
108
+ if matching_event
109
+ message = "Expected no '#{name}' event to be reported, but found:\n " \
110
+ "#{matching_event.inspect}"
111
+ flunk(message)
112
+ end
113
+ assert(true)
114
+ end
115
+ end
116
+
117
+ # Asserts that the block causes an event with the given name to be reported
118
+ # to +Rails.event+.
119
+ #
120
+ # Passes if the evaluated code in the yielded block reports a matching event.
121
+ #
122
+ # assert_event_reported("user.created") do
123
+ # Rails.event.notify("user.created", { id: 123 })
124
+ # end
125
+ #
126
+ # To test further details about the reported event, you can specify payload and tag matchers.
127
+ #
128
+ # assert_event_reported("user.created",
129
+ # payload: { id: 123, name: "John Doe" },
130
+ # tags: { request_id: /[0-9]+/ }
131
+ # ) do
132
+ # Rails.event.tagged(request_id: "123") do
133
+ # Rails.event.notify("user.created", { id: 123, name: "John Doe" })
134
+ # end
135
+ # end
136
+ #
137
+ # The matchers support partial matching - only the specified keys need to match.
138
+ #
139
+ # assert_event_reported("user.created", payload: { id: 123 }) do
140
+ # Rails.event.notify("user.created", { id: 123, name: "John Doe" })
141
+ # end
142
+ def assert_event_reported(name, payload: nil, tags: {}, &block)
143
+ events = EventCollector.record(&block)
144
+
145
+ if events.empty?
146
+ flunk("Expected an event to be reported, but there were no events reported.")
147
+ elsif (event = events.find { |event| event.matches?(name, payload, tags) })
148
+ assert(true)
149
+ event.event_data
150
+ else
151
+ message = "Expected an event to be reported matching:\n " \
152
+ "name: #{name}\n " \
153
+ "payload: #{payload.inspect}\n " \
154
+ "tags: #{tags.inspect}\n" \
155
+ "but none of the #{events.size} reported events matched:\n " \
156
+ "#{events.map(&:inspect).join("\n ")}"
157
+ flunk(message)
158
+ end
159
+ end
160
+
161
+ # Asserts that the provided events were reported, regardless of order.
162
+ #
163
+ # assert_events_reported([
164
+ # { name: "user.created", payload: { id: 123 } },
165
+ # { name: "email.sent", payload: { to: "user@example.com" } }
166
+ # ]) do
167
+ # create_user_and_send_welcome_email
168
+ # end
169
+ #
170
+ # Supports the same payload and tag matching as +assert_event_reported+.
171
+ #
172
+ # assert_events_reported([
173
+ # {
174
+ # name: "process.started",
175
+ # payload: { id: 123 },
176
+ # tags: { request_id: /[0-9]+/ }
177
+ # },
178
+ # { name: "process.completed" }
179
+ # ]) do
180
+ # Rails.event.tagged(request_id: "456") do
181
+ # start_and_complete_process(123)
182
+ # end
183
+ # end
184
+ def assert_events_reported(expected_events, &block)
185
+ events = EventCollector.record(&block)
186
+
187
+ if events.empty? && expected_events.size > 0
188
+ flunk("Expected #{expected_events.size} events to be reported, but there were no events reported.")
189
+ end
190
+
191
+ events_copy = events.dup
192
+
193
+ expected_events.each do |expected_event|
194
+ name = expected_event[:name]
195
+ payload = expected_event[:payload] || {}
196
+ tags = expected_event[:tags] || {}
197
+
198
+ matching_event_index = events_copy.find_index { |event| event.matches?(name, payload, tags) }
199
+
200
+ if matching_event_index
201
+ events_copy.delete_at(matching_event_index)
202
+ else
203
+ message = "Expected an event to be reported matching:\n " \
204
+ "name: #{name.inspect}\n " \
205
+ "payload: #{payload.inspect}\n " \
206
+ "tags: #{tags.inspect}\n" \
207
+ "but none of the #{events.size} reported events matched:\n " \
208
+ "#{events.map(&:inspect).join("\n ")}"
209
+ flunk(message)
210
+ end
211
+ end
212
+
213
+ assert(true)
214
+ end
215
+ end
216
+ end
217
+ end