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
@@ -41,7 +41,6 @@ module ActiveSupport
41
41
 
42
42
  prepend Strategy::LocalCache
43
43
 
44
- KEY_MAX_SIZE = 250
45
44
  ESCAPE_KEY_CHARS = /[\x00-\x20%\x7F-\xFF]/n
46
45
 
47
46
  # Creates a new Dalli::Client instance with specified addresses and options.
@@ -80,6 +79,7 @@ module ActiveSupport
80
79
  if options.key?(:cache_nils)
81
80
  options[:skip_nil] = !options.delete(:cache_nils)
82
81
  end
82
+ options[:max_key_size] ||= MAX_KEY_SIZE
83
83
  super(options)
84
84
 
85
85
  unless [String, Dalli::Client, NilClass].include?(addresses.first.class)
@@ -110,9 +110,6 @@ module ActiveSupport
110
110
  # * <tt>raw: true</tt> - Sends the value directly to the server as raw
111
111
  # bytes. The value must be a string or number. You can use memcached
112
112
  # direct operations like +increment+ and +decrement+ only on raw values.
113
- #
114
- # * <tt>unless_exist: true</tt> - Prevents overwriting an existing cache
115
- # entry.
116
113
 
117
114
  # Increment a cached integer value using the memcached incr atomic operator.
118
115
  # Returns the updated value.
@@ -129,6 +126,11 @@ module ActiveSupport
129
126
  #
130
127
  # Incrementing a non-numeric value, or a value written without
131
128
  # <tt>raw: true</tt>, will fail and return +nil+.
129
+ #
130
+ # To read the value later, call #read_counter:
131
+ #
132
+ # cache.increment("baz") # => 7
133
+ # cache.read_counter("baz") # 7
132
134
  def increment(name, amount = 1, options = nil)
133
135
  options = merged_options(options)
134
136
  key = normalize_key(name, options)
@@ -155,6 +157,11 @@ module ActiveSupport
155
157
  #
156
158
  # Decrementing a non-numeric value, or a value written without
157
159
  # <tt>raw: true</tt>, will fail and return +nil+.
160
+ #
161
+ # To read the value later, call #read_counter:
162
+ #
163
+ # cache.decrement("baz") # => 3
164
+ # cache.read_counter("baz") # 3
158
165
  def decrement(name, amount = 1, options = nil)
159
166
  options = merged_options(options)
160
167
  key = normalize_key(name, options)
@@ -249,19 +256,12 @@ module ActiveSupport
249
256
  # before applying the regular expression to ensure we are escaping all
250
257
  # characters properly.
251
258
  def normalize_key(key, options)
252
- key = super
259
+ key = expand_and_namespace_key(key, options)
253
260
  if key
254
261
  key = key.dup.force_encoding(Encoding::ASCII_8BIT)
255
262
  key = key.gsub(ESCAPE_KEY_CHARS) { |match| "%#{match.getbyte(0).to_s(16).upcase}" }
256
-
257
- if key.size > KEY_MAX_SIZE
258
- key_separator = ":hash:"
259
- key_hash = ActiveSupport::Digest.hexdigest(key)
260
- key_trim_size = KEY_MAX_SIZE - key_separator.size - key_hash.size
261
- key = "#{key[0, key_trim_size]}#{key_separator}#{key_hash}"
262
- end
263
263
  end
264
- key
264
+ truncate_key(key)
265
265
  end
266
266
 
267
267
  def deserialize_entry(payload, raw: false, **)
@@ -35,9 +35,6 @@ module ActiveSupport
35
35
  # +Redis::Distributed+ 4.0.1+ for distributed mget support.
36
36
  # * +delete_matched+ support for Redis KEYS globs.
37
37
  class RedisCacheStore < Store
38
- # Keys are truncated with the Active Support digest if they exceed 1kB
39
- MAX_KEY_BYTESIZE = 1024
40
-
41
38
  DEFAULT_REDIS_OPTIONS = {
42
39
  connect_timeout: 1,
43
40
  read_timeout: 1,
@@ -106,20 +103,29 @@ module ActiveSupport
106
103
  end
107
104
  end
108
105
 
109
- attr_reader :max_key_bytesize
110
106
  attr_reader :redis
111
107
 
112
108
  # Creates a new Redis cache store.
113
109
  #
114
- # There are four ways to provide the Redis client used by the cache: the
115
- # +:redis+ param can be a Redis instance or a block that returns a Redis
116
- # instance, or the +:url+ param can be a string or an array of strings
117
- # which will be used to create a Redis instance or a +Redis::Distributed+
118
- # instance.
110
+ # There are a few ways to provide the Redis client used by the cache:
111
+ #
112
+ # 1. The +:redis+ param can be:
113
+ # - A Redis instance.
114
+ # - A +ConnectionPool+ instance wrapping a Redis instance.
115
+ # - A block that returns a Redis instance.
116
+ #
117
+ # 2. The +:url+ param can be:
118
+ # - A string used to create a Redis instance.
119
+ # - An array of strings used to create a +Redis::Distributed+ instance.
120
+ #
121
+ # If the final Redis instance is not already a +ConnectionPool+, it will
122
+ # be wrapped in one using +ActiveSupport::Cache::Store::DEFAULT_POOL_OPTIONS+.
123
+ # These options can be overridden with the +:pool+ param, or the pool can be
124
+ # disabled with +:pool: false+.
119
125
  #
120
126
  # Option Class Result
121
- # :redis Proc -> options[:redis].call
122
127
  # :redis Object -> options[:redis]
128
+ # :redis Proc -> options[:redis].call
123
129
  # :url String -> Redis.new(url: …)
124
130
  # :url Array -> Redis::Distributed.new([{ url: … }, { url: … }, …])
125
131
  #
@@ -148,14 +154,17 @@ module ActiveSupport
148
154
  # cache.exist?('bar') # => false
149
155
  def initialize(error_handler: DEFAULT_ERROR_HANDLER, **redis_options)
150
156
  universal_options = redis_options.extract!(*UNIVERSAL_OPTIONS)
157
+ redis = redis_options[:redis]
158
+
159
+ already_pool = redis.instance_of?(::ConnectionPool) ||
160
+ (redis.respond_to?(:wrapped_pool) && redis.wrapped_pool.instance_of?(::ConnectionPool))
151
161
 
152
- if pool_options = self.class.send(:retrieve_pool_options, redis_options)
162
+ if !already_pool && pool_options = self.class.send(:retrieve_pool_options, redis_options)
153
163
  @redis = ::ConnectionPool.new(pool_options) { self.class.build_redis(**redis_options) }
154
164
  else
155
165
  @redis = self.class.build_redis(**redis_options)
156
166
  end
157
167
 
158
- @max_key_bytesize = MAX_KEY_BYTESIZE
159
168
  @error_handler = error_handler
160
169
 
161
170
  super(universal_options)
@@ -213,7 +222,7 @@ module ActiveSupport
213
222
  nodes.each do |node|
214
223
  begin
215
224
  cursor, keys = node.scan(cursor, match: pattern, count: SCAN_BATCH_SIZE)
216
- node.del(*keys) unless keys.empty?
225
+ node.unlink(*keys) unless keys.empty?
217
226
  end until cursor == "0"
218
227
  end
219
228
  end
@@ -236,6 +245,11 @@ module ActiveSupport
236
245
  # Incrementing a non-numeric value, or a value written without
237
246
  # <tt>raw: true</tt>, will fail and return +nil+.
238
247
  #
248
+ # To read the value later, call #read_counter:
249
+ #
250
+ # cache.increment("baz") # => 7
251
+ # cache.read_counter("baz") # 7
252
+ #
239
253
  # Failsafe: Raises errors.
240
254
  def increment(name, amount = 1, options = nil)
241
255
  options = merged_options(options)
@@ -263,6 +277,11 @@ module ActiveSupport
263
277
  # Decrementing a non-numeric value, or a value written without
264
278
  # <tt>raw: true</tt>, will fail and return +nil+.
265
279
  #
280
+ # To read the value later, call #read_counter:
281
+ #
282
+ # cache.decrement("baz") # => 3
283
+ # cache.read_counter("baz") # 3
284
+ #
266
285
  # Failsafe: Raises errors.
267
286
  def decrement(name, amount = 1, options = nil)
268
287
  options = merged_options(options)
@@ -384,14 +403,16 @@ module ActiveSupport
384
403
  # Delete an entry from the cache.
385
404
  def delete_entry(key, **options)
386
405
  failsafe :delete_entry, returning: false do
387
- redis.then { |c| c.del(key) == 1 }
406
+ redis.then { |c| c.unlink(key) == 1 }
388
407
  end
389
408
  end
390
409
 
391
410
  # Deletes multiple entries in the cache. Returns the number of entries deleted.
392
411
  def delete_multi_entries(entries, **_options)
412
+ return 0 if entries.empty?
413
+
393
414
  failsafe :delete_multi_entries, returning: 0 do
394
- redis.then { |c| c.del(entries) }
415
+ redis.then { |c| c.unlink(*entries) }
395
416
  end
396
417
  end
397
418
 
@@ -410,21 +431,6 @@ module ActiveSupport
410
431
  end
411
432
  end
412
433
 
413
- # Truncate keys that exceed 1kB.
414
- def normalize_key(key, options)
415
- truncate_key super&.b
416
- end
417
-
418
- def truncate_key(key)
419
- if key && key.bytesize > max_key_bytesize
420
- suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
421
- truncate_at = max_key_bytesize - suffix.bytesize
422
- "#{key.byteslice(0, truncate_at)}#{suffix}"
423
- else
424
- key
425
- end
426
- end
427
-
428
434
  def deserialize_entry(payload, raw: false, **)
429
435
  if raw && !payload.nil?
430
436
  Entry.new(payload)
@@ -68,12 +68,25 @@ module ActiveSupport
68
68
  use_temporary_local_cache(LocalStore.new, &block)
69
69
  end
70
70
 
71
+ # Set a new local cache.
72
+ def new_local_cache
73
+ LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new)
74
+ end
75
+
76
+ # Unset the current local cache.
77
+ def unset_local_cache
78
+ LocalCacheRegistry.set_cache_for(local_cache_key, nil)
79
+ end
80
+
81
+ # The current local cache.
82
+ def local_cache
83
+ LocalCacheRegistry.cache_for(local_cache_key)
84
+ end
85
+
71
86
  # Middleware class can be inserted as a Rack handler to be local cache for the
72
87
  # duration of request.
73
88
  def middleware
74
- @middleware ||= Middleware.new(
75
- "ActiveSupport::Cache::Strategy::LocalCache",
76
- local_cache_key)
89
+ @middleware ||= Middleware.new("ActiveSupport::Cache::Strategy::LocalCache", self)
77
90
  end
78
91
 
79
92
  def clear(options = nil) # :nodoc:
@@ -214,10 +227,6 @@ module ActiveSupport
214
227
  @local_cache_key ||= "#{self.class.name.underscore}_local_cache_#{object_id}".gsub(/[\/-]/, "_").to_sym
215
228
  end
216
229
 
217
- def local_cache
218
- LocalCacheRegistry.cache_for(local_cache_key)
219
- end
220
-
221
230
  def bypass_local_cache(&block)
222
231
  use_temporary_local_cache(nil, &block)
223
232
  end
@@ -11,11 +11,12 @@ module ActiveSupport
11
11
  # This class wraps up local storage for middlewares. Only the middleware method should
12
12
  # construct them.
13
13
  class Middleware # :nodoc:
14
- attr_reader :name, :local_cache_key
14
+ attr_reader :name
15
+ attr_accessor :cache
15
16
 
16
- def initialize(name, local_cache_key)
17
+ def initialize(name, cache)
17
18
  @name = name
18
- @local_cache_key = local_cache_key
19
+ @cache = cache
19
20
  @app = nil
20
21
  end
21
22
 
@@ -25,18 +26,17 @@ module ActiveSupport
25
26
  end
26
27
 
27
28
  def call(env)
28
- LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new)
29
+ cache.new_local_cache
29
30
  response = @app.call(env)
30
31
  response[2] = ::Rack::BodyProxy.new(response[2]) do
31
- LocalCacheRegistry.set_cache_for(local_cache_key, nil)
32
+ cache.unset_local_cache
32
33
  end
33
34
  cleanup_on_body_close = true
34
35
  response
35
36
  rescue Rack::Utils::InvalidParameterError
36
37
  [400, {}, []]
37
38
  ensure
38
- LocalCacheRegistry.set_cache_for(local_cache_key, nil) unless
39
- cleanup_on_body_close
39
+ cache.unset_local_cache unless cleanup_on_body_close
40
40
  end
41
41
  end
42
42
  end
@@ -36,6 +36,7 @@ module ActiveSupport
36
36
  :serializer,
37
37
  :skip_nil,
38
38
  :raw,
39
+ :max_key_size,
39
40
  ]
40
41
 
41
42
  # Mapping of canonical option names to aliases that a store will recognize.
@@ -187,6 +188,12 @@ module ActiveSupport
187
188
  # @last_mod_time = Time.now # Invalidate the entire cache by changing namespace
188
189
  #
189
190
  class Store
191
+ # Default +ConnectionPool+ options
192
+ DEFAULT_POOL_OPTIONS = { size: 5, timeout: 5 }.freeze
193
+
194
+ # Keys are truncated with the Active Support digest if they exceed the limit.
195
+ MAX_KEY_SIZE = 250
196
+
190
197
  cattr_accessor :logger, instance_writer: true
191
198
  cattr_accessor :raise_on_invalid_cache_expiration_time, default: false
192
199
 
@@ -195,9 +202,6 @@ module ActiveSupport
195
202
 
196
203
  class << self
197
204
  private
198
- DEFAULT_POOL_OPTIONS = { size: 5, timeout: 5 }.freeze
199
- private_constant :DEFAULT_POOL_OPTIONS
200
-
201
205
  def retrieve_pool_options(options)
202
206
  if options.key?(:pool)
203
207
  pool_options = options.delete(:pool)
@@ -299,6 +303,9 @@ module ActiveSupport
299
303
  @options[:compress] = true unless @options.key?(:compress)
300
304
  @options[:compress_threshold] ||= DEFAULT_COMPRESS_LIMIT
301
305
 
306
+ @max_key_size = @options.delete(:max_key_size)
307
+ @max_key_size = MAX_KEY_SIZE if @max_key_size.nil? # allow 'false' as a value
308
+
302
309
  @coder = @options.delete(:coder) do
303
310
  legacy_serializer = Cache.format_version < 7.1 && !@options[:serializer]
304
311
  serializer = @options.delete(:serializer) || default_serializer
@@ -557,7 +564,7 @@ module ActiveSupport
557
564
 
558
565
  instrument_multi :write_multi, normalized_hash, options do |payload|
559
566
  entries = hash.each_with_object({}) do |(name, value), memo|
560
- memo[normalize_key(name, options)] = Entry.new(value, **options.merge(version: normalize_version(name, options)))
567
+ memo[normalize_key(name, options)] = Entry.new(value, **options, version: normalize_version(name, options))
561
568
  end
562
569
 
563
570
  write_multi_entries entries, **options
@@ -659,13 +666,15 @@ module ActiveSupport
659
666
  # version, the read will be treated as a cache miss. This feature is
660
667
  # used to support recyclable cache keys.
661
668
  #
669
+ # * +:unless_exist+ - Prevents overwriting an existing cache entry.
670
+ #
662
671
  # Other options will be handled by the specific cache store implementation.
663
672
  def write(name, value, options = nil)
664
673
  options = merged_options(options)
665
674
  key = normalize_key(name, options)
666
675
 
667
676
  instrument(:write, key, options) do
668
- entry = Entry.new(value, **options.merge(version: normalize_version(name, options)))
677
+ entry = Entry.new(value, **options, version: normalize_version(name, options))
669
678
  write_entry(key, entry, **options)
670
679
  end
671
680
  end
@@ -742,6 +751,32 @@ module ActiveSupport
742
751
  raise NotImplementedError.new("#{self.class.name} does not support decrement")
743
752
  end
744
753
 
754
+ # Reads a counter that was set by #increment / #decrement.
755
+ #
756
+ # cache.write_counter("foo", 1)
757
+ # cache.read_counter("foo") # => 1
758
+ # cache.increment("foo")
759
+ # cache.read_counter("foo") # => 2
760
+ #
761
+ # Options are passed to the underlying cache implementation.
762
+ def read_counter(name, **options)
763
+ options = merged_options(options).merge(raw: true)
764
+ read(name, **options)&.to_i
765
+ end
766
+
767
+ # Writes a counter that can then be modified by #increment / #decrement.
768
+ #
769
+ # cache.write_counter("foo", 1)
770
+ # cache.read_counter("foo") # => 1
771
+ # cache.increment("foo")
772
+ # cache.read_counter("foo") # => 2
773
+ #
774
+ # Options are passed to the underlying cache implementation.
775
+ def write_counter(name, value, **options)
776
+ options = merged_options(options).merge(raw: true)
777
+ write(name, value.to_i, **options)
778
+ end
779
+
745
780
  # Cleans up the cache by removing expired entries.
746
781
  #
747
782
  # Options are passed to the underlying cache implementation.
@@ -761,6 +796,17 @@ module ActiveSupport
761
796
  raise NotImplementedError.new("#{self.class.name} does not support clear")
762
797
  end
763
798
 
799
+ # Get the current namespace
800
+ def namespace
801
+ @options[:namespace]
802
+ end
803
+
804
+ # Set the current namespace. Note, this will be ignored if custom
805
+ # options are passed to cache wills with a namespace key.
806
+ def namespace=(namespace)
807
+ @options[:namespace] = namespace
808
+ end
809
+
764
810
  private
765
811
  def default_serializer
766
812
  case Cache.format_version
@@ -927,16 +973,33 @@ module ActiveSupport
927
973
  options
928
974
  end
929
975
 
930
- # Expands and namespaces the cache key.
976
+ # Expands, namespaces and truncates the cache key.
931
977
  # Raises an exception when the key is +nil+ or an empty string.
932
978
  # May be overridden by cache stores to do additional normalization.
933
979
  def normalize_key(key, options = nil)
980
+ key = expand_and_namespace_key(key, options)
981
+ truncate_key(key)
982
+ end
983
+
984
+ def expand_and_namespace_key(key, options = nil)
934
985
  str_key = expanded_key(key)
935
986
  raise(ArgumentError, "key cannot be blank") if !str_key || str_key.empty?
936
987
 
937
988
  namespace_key str_key, options
938
989
  end
939
990
 
991
+ def truncate_key(key)
992
+ if key && @max_key_size && key.bytesize > @max_key_size
993
+ suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
994
+ truncate_at = @max_key_size - suffix.bytesize
995
+ key = key.byteslice(0, truncate_at)
996
+ key.scrub!("")
997
+ "#{key}#{suffix}"
998
+ else
999
+ key
1000
+ end
1001
+ end
1002
+
940
1003
  # Prefix the key with a namespace string:
941
1004
  #
942
1005
  # namespace_key 'foo', namespace: 'cache'
@@ -27,6 +27,19 @@ module ActiveSupport
27
27
  end
28
28
 
29
29
  module ClassMethods
30
+ # Reads and writes attributes from a configuration OrderedOptions.
31
+ #
32
+ # require "active_support/configurable"
33
+ #
34
+ # class User
35
+ # include ActiveSupport::Configurable
36
+ # end
37
+ #
38
+ # User.config.allowed_access = true
39
+ # User.config.level = 1
40
+ #
41
+ # User.config.allowed_access # => true
42
+ # User.config.level # => 1
30
43
  def config
31
44
  @_config ||= if respond_to?(:superclass) && superclass.respond_to?(:config)
32
45
  superclass.config.inheritable_copy
@@ -36,6 +49,21 @@ module ActiveSupport
36
49
  end
37
50
  end
38
51
 
52
+ # Configure values from within the passed block.
53
+ #
54
+ # require "active_support/configurable"
55
+ #
56
+ # class User
57
+ # include ActiveSupport::Configurable
58
+ # end
59
+ #
60
+ # User.allowed_access # => nil
61
+ #
62
+ # User.configure do |config|
63
+ # config.allowed_access = true
64
+ # end
65
+ #
66
+ # User.allowed_access # => true
39
67
  def configure
40
68
  yield config
41
69
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveSupport
4
+ # Provides a DSL for declaring a continuous integration workflow that can be run either locally or in the cloud.
5
+ # Each step is timed, reports success/error, and is aggregated into a collective report that reports total runtime,
6
+ # as well as whether the entire run was successful or not.
7
+ #
8
+ # Example:
9
+ #
10
+ # ActiveSupport::ContinuousIntegration.run do
11
+ # step "Setup", "bin/setup --skip-server"
12
+ # step "Style: Ruby", "bin/rubocop"
13
+ # step "Security: Gem audit", "bin/bundler-audit"
14
+ # step "Tests: Rails", "bin/rails test test:system"
15
+ #
16
+ # if success?
17
+ # step "Signoff: Ready for merge and deploy", "gh signoff"
18
+ # else
19
+ # failure "Skipping signoff; CI failed.", "Fix the issues and try again."
20
+ # end
21
+ # end
22
+ #
23
+ # Starting with Rails 8.1, a default `bin/ci` and `config/ci.rb` file are created to provide out-of-the-box CI.
24
+ class ContinuousIntegration
25
+ COLORS = {
26
+ banner: "\033[1;32m", # Green
27
+ title: "\033[1;35m", # Purple
28
+ subtitle: "\033[1;90m", # Medium Gray
29
+ error: "\033[1;31m", # Red
30
+ success: "\033[1;32m" # Green
31
+ }
32
+
33
+ attr_reader :results
34
+
35
+ # Perform a CI run. Execute each step, show their results and runtime, and exit with a non-zero status if there are any failures.
36
+ #
37
+ # Pass an optional title, subtitle, and a block that declares the steps to be executed.
38
+ #
39
+ # Sets the CI environment variable to "true" to allow for conditional behavior in the app, like enabling eager loading and disabling logging.
40
+ #
41
+ # Example:
42
+ #
43
+ # ActiveSupport::ContinuousIntegration.run do
44
+ # step "Setup", "bin/setup --skip-server"
45
+ # step "Style: Ruby", "bin/rubocop"
46
+ # step "Security: Gem audit", "bin/bundler-audit"
47
+ # step "Tests: Rails", "bin/rails test test:system"
48
+ #
49
+ # if success?
50
+ # step "Signoff: Ready for merge and deploy", "gh signoff"
51
+ # else
52
+ # failure "Skipping signoff; CI failed.", "Fix the issues and try again."
53
+ # end
54
+ # end
55
+ def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block)
56
+ new.tap do |ci|
57
+ ENV["CI"] = "true"
58
+ ci.heading title, subtitle, padding: false
59
+ ci.report(title, &block)
60
+ abort unless ci.success?
61
+ end
62
+ end
63
+
64
+ def initialize
65
+ @results = []
66
+ end
67
+
68
+ # Declare a step with a title and a command. The command can either be given as a single string or as multiple
69
+ # strings that will be passed to `system` as individual arguments (and therefore correctly escaped for paths etc).
70
+ #
71
+ # Examples:
72
+ #
73
+ # step "Setup", "bin/setup"
74
+ # step "Single test", "bin/rails", "test", "--name", "test_that_is_one"
75
+ def step(title, *command)
76
+ heading title, command.join(" "), type: :title
77
+ report(title) { results << system(*command) }
78
+ end
79
+
80
+ # Returns true if all steps were successful.
81
+ def success?
82
+ results.all?
83
+ end
84
+
85
+ # Display an error heading with the title and optional subtitle to reflect that the run failed.
86
+ def failure(title, subtitle = nil)
87
+ heading title, subtitle, type: :error
88
+ end
89
+
90
+ # Display a colorized heading followed by an optional subtitle.
91
+ #
92
+ # Examples:
93
+ #
94
+ # heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
95
+ # heading "Skipping video encoding tests", "Install FFmpeg to run these tests", type: :error
96
+ #
97
+ # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
98
+ def heading(heading, subtitle = nil, type: :banner, padding: true)
99
+ echo "#{padding ? "\n\n" : ""}#{heading}", type: type
100
+ echo "#{subtitle}#{padding ? "\n" : ""}", type: :subtitle if subtitle
101
+ end
102
+
103
+ # Echo text to the terminal in the color corresponding to the type of the text.
104
+ #
105
+ # Examples:
106
+ #
107
+ # echo "This is going to be green!", type: :success
108
+ # echo "This is going to be red!", type: :error
109
+ #
110
+ # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
111
+ def echo(text, type:)
112
+ puts colorize(text, type)
113
+ end
114
+
115
+ # :nodoc:
116
+ def report(title, &block)
117
+ Signal.trap("INT") { abort colorize(:error, "\n❌ #{title} interrupted") }
118
+
119
+ ci = self.class.new
120
+ elapsed = timing { ci.instance_eval(&block) }
121
+
122
+ if ci.success?
123
+ echo "\n✅ #{title} passed in #{elapsed}", type: :success
124
+ else
125
+ echo "\n❌ #{title} failed in #{elapsed}", type: :error
126
+ end
127
+
128
+ results.concat ci.results
129
+ ensure
130
+ Signal.trap("INT", "-")
131
+ end
132
+
133
+ private
134
+ def timing
135
+ started_at = Time.now.to_f
136
+ yield
137
+ min, sec = (Time.now.to_f - started_at).divmod(60)
138
+ "#{"#{min}m" if min > 0}%.2fs" % sec
139
+ end
140
+
141
+ def colorize(text, type)
142
+ "#{COLORS.fetch(type)}#{text}\033[0m"
143
+ end
144
+ end
145
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "benchmark"
4
- return if Benchmark.respond_to?(:ms)
5
4
 
6
5
  class << Benchmark
7
6
  def ms(&block) # :nodoc
@@ -17,7 +17,7 @@ module DateAndTime
17
17
  singleton_class.silence_redefinition_of_method :preserve_timezone
18
18
 
19
19
  #--
20
- # This re-implements the behaviour of the mattr_reader, instead
20
+ # This re-implements the behavior of the mattr_reader, instead
21
21
  # of prepending on to it, to avoid overcomplicating a module that
22
22
  # is in turn included in several places. This will all go away in
23
23
  # Rails 8.0 anyway.
@@ -12,7 +12,7 @@ module ActiveSupport
12
12
  if s.html_safe?
13
13
  s
14
14
  else
15
- super(ActiveSupport::Multibyte::Unicode.tidy_bytes(s))
15
+ super(s)
16
16
  end
17
17
  end
18
18
  alias :unwrapped_html_escape :html_escape # :nodoc:
@@ -61,7 +61,7 @@ class ERB
61
61
  # html_escape_once('&lt;&lt; Accept & Checkout')
62
62
  # # => "&lt;&lt; Accept &amp; Checkout"
63
63
  def html_escape_once(s)
64
- ActiveSupport::Multibyte::Unicode.tidy_bytes(s.to_s).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE).html_safe
64
+ s.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE).html_safe
65
65
  end
66
66
 
67
67
  module_function :html_escape_once
@@ -169,7 +169,7 @@ class ERB
169
169
  while !source.eos?
170
170
  pos = source.pos
171
171
  source.scan_until(/(?:#{start_re}|#{finish_re})/)
172
- raise NotImplementedError if source.matched.nil?
172
+ return [[:PLAIN, source.string]] unless source.matched?
173
173
  len = source.pos - source.matched.bytesize - pos
174
174
 
175
175
  case source.matched