right_develop 3.1.8 → 3.1.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 04ae23ffcb2adcaf347b19ff9f25fc3d4b179623
4
- data.tar.gz: f272d7a94045c77748e6a4c5887082d56ab4c042
3
+ metadata.gz: f9bea4294ac48a9bb1c124143e96594b3993867c
4
+ data.tar.gz: 1817c459f9d80fea412f1e1e7a66c011c6629f0d
5
5
  SHA512:
6
- metadata.gz: a80de468e508630973d7d859561e5a08dfa6dc1ec35dd33e9558bc1f2a2323e069ce91b2bede4486efdb8e7abbabc2ca96f4224180895a78f953edb4eb14fb28
7
- data.tar.gz: 60e69d6668880d62ada2a1862319f109f8200dfa12969377c8bbc50c0c99a17391e75d361b2d156ee5095a9d96d73f2134f74cca56690eb33683a9f548b885d9
6
+ metadata.gz: 814de826f973fd7678c8723ea686bebc140aa18e39497e7d77d683c208dc6dddaa8895ab0bf121400c0005862ba01b49806c2ee9462863d4be55d27b71d20b5c
7
+ data.tar.gz: 65416ad1cbe5429b1e74b842449b739992f9d1e2d32e53569c209932f6ab8204e43c1596e576ff290d7995d15d393e9bfc62d3057b50bb4d28d7691bb8443e7e
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.8
1
+ 3.1.9
@@ -40,9 +40,10 @@ module RightDevelop::Testing::Client::Rest::Request
40
40
 
41
41
  # fake Net::HTTPResponse
42
42
  class FakeNetHttpResponse
43
- attr_reader :code, :body, :elapsed_seconds, :call_count
43
+ attr_reader :code, :body, :delay_seconds, :elapsed_seconds, :call_count
44
44
 
45
45
  def initialize(response_hash, response_metadata)
46
+ @delay_seconds = response_metadata.delay_seconds
46
47
  @elapsed_seconds = Integer(response_hash[:elapsed_seconds] || 0)
47
48
  @code = response_metadata.http_status.to_s
48
49
  @headers = response_metadata.headers.inject({}) do |h, (k, v)|
@@ -140,6 +141,15 @@ module RightDevelop::Testing::Client::Rest::Request
140
141
  logger.debug("throttle delay = #{delay}")
141
142
  sleep delay
142
143
  end
144
+
145
+ # there may be a configured response delay (in addition to throttling)
146
+ # which allows for other responses to complete before the current
147
+ # response thread is unblocked. the response delay is absolute and not
148
+ # subject to the throttle factor.
149
+ if (delay = response.delay_seconds) > 0
150
+ logger.debug("configured response delay = #{delay}")
151
+ sleep delay
152
+ end
143
153
  log_response(response)
144
154
  process_result(response, &block)
145
155
  else
@@ -170,11 +180,15 @@ module RightDevelop::Testing::Client::Rest::Request
170
180
  file_path = nil
171
181
  past_epochs = state[:past_epochs] ||= []
172
182
  try_epochs = [state[:epoch]] + past_epochs
173
- tried_paths = []
183
+ first_tried_path = nil
184
+ first_tried_epoch = nil
185
+ last_tried_epoch = nil
174
186
  try_epochs.each do |epoch|
175
187
  file_path = response_file_path(epoch)
176
188
  break if ::File.file?(file_path)
177
- tried_paths << file_path
189
+ first_tried_path = file_path unless first_tried_path
190
+ first_tried_epoch = epoch unless first_tried_epoch
191
+ last_tried_epoch = epoch
178
192
  file_path = nil
179
193
  end
180
194
  if file_path
@@ -189,8 +203,10 @@ module RightDevelop::Testing::Client::Rest::Request
189
203
  response_hash[:body])
190
204
  result = FakeNetHttpResponse.new(response_hash, response_metadata)
191
205
  else
192
- raise PLAYBACK_ERROR,
193
- "Unable to locate response file(s): \"#{tried_paths.join("\", \"")}\""
206
+ msg = 'Unable to locate response file(s) in epoch range ' +
207
+ "[#{first_tried_epoch} - #{last_tried_epoch}]:\n " +
208
+ first_tried_path.inspect
209
+ raise PLAYBACK_ERROR, msg
194
210
  end
195
211
  logger.debug("Played back response from #{file_path.inspect}.")
196
212
 
@@ -52,7 +52,7 @@ module RightDevelop::Testing::Recording
52
52
 
53
53
  # keys allowed under the deep route configuration.
54
54
  ALLOWED_KINDS = %w(request response)
55
- ALLOWED_CONFIG_ACTIONS = %w(significant timeouts transform variables)
55
+ ALLOWED_CONFIG_ACTIONS = %w(delay_seconds significant timeouts transform variables)
56
56
  ALLOWED_TIMEOUTS = %w(open_timeout read_timeout)
57
57
  ALLOWED_VARIABLE_TYPES = %w(body header query)
58
58
 
@@ -178,11 +178,15 @@ module RightDevelop::Testing::Recording
178
178
  # normalize routes for efficient usage but keep them separate from
179
179
  # user's config so that .to_hash returns something understandable and
180
180
  # JSONizable/YAMLable.
181
- @normalized_routes = value.inject(RightSupport::Data::Mash.new) do |r, (k, v)|
181
+ mutable_routes = value.inject(RightSupport::Data::Mash.new) do |r, (k, v)|
182
182
  r[normalize_route_prefix(k)] = normalize_route_data(k, v)
183
183
  r
184
184
  end
185
- @config_hash['routes'] = ::RightSupport::Data::HashTools.deep_clone(value)
185
+
186
+ # deep freeze routes to detect any case where code is unintentionally
187
+ # modifying the route hash.
188
+ @normalized_routes = ::RightSupport::Data::HashTools.deep_freeze!(mutable_routes)
189
+ @config_hash['routes'] = ::RightSupport::Data::HashTools.deep_clone2(value)
186
190
  else
187
191
  raise ConfigError, 'routes must be a hash'
188
192
  end
@@ -554,6 +558,15 @@ module RightDevelop::Testing::Recording
554
558
 
555
559
  rst[rst_k] = rst_v.inject(RightSupport::Data::Mash.new) do |kc, (kc_k, kc_v)|
556
560
  case kc_k
561
+ when METADATA_CLASS::DELAY_SECONDS_KEY
562
+ begin
563
+ kc_v = Float(kc_v)
564
+ rescue ::ArgumentError
565
+ location = position_string(position, subpath + [rst_k, kc_k])
566
+ message = 'Invalid route configuration delay_seconds value at ' +
567
+ "#{location}: #{kc_v.inspect}"
568
+ raise ConfigError, message
569
+ end
557
570
  when METADATA_CLASS::TIMEOUTS_KEY
558
571
  # sanity check.
559
572
  kc_v = kc_v.inject(RightSupport::Data::Mash.new) do |h, (k, v)|
@@ -43,11 +43,12 @@ module RightDevelop::Testing::Recording
43
43
  KINDS = %w(request response)
44
44
 
45
45
  # route-relative config keys.
46
- MATCHERS_KEY = 'matchers'.freeze
47
- SIGNIFICANT_KEY = 'significant'.freeze
48
- TIMEOUTS_KEY = 'timeouts'.freeze
49
- TRANSFORM_KEY = 'transform'.freeze
50
- VARIABLES_KEY = 'variables'.freeze
46
+ DELAY_SECONDS_KEY = 'delay_seconds'.freeze
47
+ MATCHERS_KEY = 'matchers'.freeze
48
+ SIGNIFICANT_KEY = 'significant'.freeze
49
+ TIMEOUTS_KEY = 'timeouts'.freeze
50
+ TRANSFORM_KEY = 'transform'.freeze
51
+ VARIABLES_KEY = 'variables'.freeze
51
52
 
52
53
  # finds the value index for a recorded variable, if any.
53
54
  VARIABLE_INDEX_REGEX = /\[(\d+)\]$/
@@ -83,6 +84,7 @@ module RightDevelop::Testing::Recording
83
84
  end
84
85
 
85
86
  # exceptions.
87
+ class ConfigurationError < StandardError; end
86
88
  class RecordingError < StandardError; end
87
89
  class PlaybackError < StandardError; end
88
90
 
@@ -142,11 +144,12 @@ module RightDevelop::Testing::Recording
142
144
 
143
145
  # apply the configuration by substituting for variables in the request and
144
146
  # by obfuscating wherever a variable name is nil.
147
+ erck = @effective_route_config[@kind]
145
148
  case @mode
146
149
  when 'validate'
147
- # do nothing; used to validate the fixtures before playback, etc.
150
+ # used to validate the fixtures before playback; no variable
151
+ # substitution should be performed.
148
152
  else
149
- erck = @effective_route_config[@kind]
150
153
  if effective_variables = erck && erck[VARIABLES_KEY]
151
154
  recursive_replace_variables(
152
155
  [@kind, VARIABLES_KEY],
@@ -154,10 +157,10 @@ module RightDevelop::Testing::Recording
154
157
  effective_variables,
155
158
  erck[TRANSFORM_KEY])
156
159
  end
157
- if logger.debug?
158
- logger.debug("#{@kind} effective_route_config = #{@effective_route_config[@kind].inspect}")
159
- logger.debug("#{@kind} typenames_to_values = #{@typenames_to_values.inspect}")
160
- end
160
+ end
161
+ if logger.debug?
162
+ logger.debug("#{@kind} effective_route_config = #{@effective_route_config[@kind].inspect}")
163
+ logger.debug("#{@kind} typenames_to_values = #{@typenames_to_values.inspect}")
161
164
  end
162
165
 
163
166
  # recreate headers and body from data using variable substitutions and
@@ -181,6 +184,12 @@ module RightDevelop::Testing::Recording
181
184
  @checksum ||= compute_checksum
182
185
  end
183
186
 
187
+ # @return [Float] delay in seconds (of response) from effective
188
+ # configuration or empty
189
+ def delay_seconds
190
+ Float((@effective_route_config[@kind] || {})[DELAY_SECONDS_KEY] || 0)
191
+ end
192
+
184
193
  # @return [Hash] timeouts from effective configuration or empty
185
194
  def timeouts
186
195
  (@effective_route_config[@kind] || {})[TIMEOUTS_KEY] || {}
@@ -347,7 +356,7 @@ module RightDevelop::Testing::Recording
347
356
  # note that we never want to fail to proxy back a response because
348
357
  # the server doesn't know what JSON is; log a warning and continue.
349
358
  # this actually happens with Right API 1.5 health-check saying
350
- # response is 'application/json' but returning "ok"
359
+ # response is 'application/json' but returning "\"ok\""
351
360
  logger.warn("Failed to parse JSON data from #{body.inspect}: #{e.class} #{e.message}")
352
361
  end
353
362
  else
@@ -377,10 +386,10 @@ module RightDevelop::Testing::Recording
377
386
  when ::Hash, ::Array
378
387
  body_hash = body
379
388
  when ::String
380
- body_hash = parse_body(normalized_headers, body)
381
- return body unless body_hash
382
- else
383
- return body
389
+ # use unparsed original body to avoid losing information when we are
390
+ # unable to parse or parse a literal JSON string as happens in the case
391
+ # of RightAPI's health-check.
392
+ return @body
384
393
  end
385
394
  case ct = compute_content_type(normalized_headers)
386
395
  when 'application/x-www-form-urlencoded'
@@ -433,11 +442,16 @@ module RightDevelop::Testing::Recording
433
442
  match_deep(@typenames_to_values[qualifier_type], qualifier_name_to_value)
434
443
  end
435
444
  if all_matched
445
+ # note that we must be careful to deep clone the existing result
446
+ # before merging as any previously-merged configuration (shared
447
+ # from the original configuration) would be modified by deep
448
+ # merging. use .deep_merge instead of .deep_merge!
449
+ #
436
450
  # the final data is the union of all configurations matching
437
451
  # this request path and qualifiers. the uri regex and other
438
452
  # data used to match the request parameters is eliminated from
439
453
  # the final configuration.
440
- ::RightSupport::Data::HashTools.deep_merge!(result, configuration)
454
+ result = ::RightSupport::Data::HashTools.deep_merge(result, configuration)
441
455
  end
442
456
  end
443
457
  end
@@ -771,37 +785,15 @@ module RightDevelop::Testing::Recording
771
785
  significant_data = RightSupport::Data::Mash.new(verb: @verb)
772
786
  significant_data[:http_status] = @http_status if @http_status
773
787
 
774
- # headers
775
- copy_if_significant(:header, significant, significant_data)
776
-
777
- # query
778
- unless copy_if_significant(:query, significant, significant_data)
779
- # entire query string is significant by default.
780
- significant_data[:query] = @typenames_to_values[:query]
781
- end
782
-
783
- # body
784
- unless copy_if_significant(:body, significant, significant_data)
785
- case body_value = @typenames_to_values[:body]
786
- when nil
787
- # body is either nil, empty or was not parsable; insert the checksum
788
- # of the original body.
789
- case @body
790
- when nil, '', ' '
791
- significant_data[:body_checksum] = 'empty'
792
- else
793
- significant_data[:body_checksum] = ::Digest::MD5.hexdigest(@body)
794
- end
795
- else
796
- # body was parsed but no single element was considered significant.
797
- # use the parsed body so that it can be 'normalized' in sorted order.
798
- significant_data[:body] = body_value
799
- end
788
+ # significance by type.
789
+ [:header, :query, :body].each do |type|
790
+ copy_if_significant(type, significant, significant_data)
800
791
  end
801
792
 
802
793
  # use deep-sorted JSON to prevent random ordering changing the checksum.
803
794
  checksum_data = self.class.deep_sorted_json(significant_data)
804
- if logger.debug? && @mode != 'validate'
795
+ if logger.debug?
796
+ logger.debug("#{@kind} significant = #{significant.inspect}")
805
797
  logger.debug("#{@kind} checksum_data = #{checksum_data.inspect}")
806
798
  end
807
799
  ::Digest::MD5.hexdigest(checksum_data)
@@ -815,41 +807,138 @@ module RightDevelop::Testing::Recording
815
807
  # @param [Hash] significant selectors
816
808
  # @param [Hash] significant_data to populate
817
809
  #
818
- # @return [TrueClass|FalseClass] true if any were significant
810
+ # @return [TrueClass] always true
819
811
  def copy_if_significant(type, significant, significant_data)
820
- if significant_type = significant[type]
821
- significant_data[type] = recursive_selective_hash_copy(
822
- RightSupport::Data::Mash.new, @typenames_to_values[type], significant_type)
823
- true
812
+ case significant_type = significant[type]
813
+ when nil
814
+ # no explicit significance declared; use the default behavior for type.
815
+ default_copy_if_significant(type, significant_data)
816
+ when false
817
+ # no fields are significant; copy nothing.
818
+ when true
819
+ # all fields are significant; copy everything.
820
+ significant_data[type] = @typenames_to_values[type]
824
821
  else
825
- false
822
+ # recursively copy significant values from hash.
823
+ significant_data[type] = recursive_selective_hash_copy(
824
+ @typenames_to_values[type],
825
+ significant_type,
826
+ [type])
826
827
  end
828
+ true
827
829
  end
828
830
 
829
- # Recursively selects and copies values from source to target.
830
- def recursive_selective_hash_copy(target, source, selections, path = [])
831
- selections.each do |k, v|
832
- case v
831
+ # Copies fields by type only if considered significant by default.
832
+ #
833
+ # @param [String|Symbol] type of significance
834
+ # @param [Hash] significant_data to populate
835
+ #
836
+ # @return [TrueClass] always true
837
+ def default_copy_if_significant(type, significant_data)
838
+ case type
839
+ when :header
840
+ # headers are insignificant by default
841
+ when :query
842
+ significant_data[:query] = @typenames_to_values[:query]
843
+ when :body
844
+ case body_value = @typenames_to_values[:body]
833
845
  when nil
834
- # hash to nil; user configured by using flat hashes instead of arrays.
835
- # it's a style thing that makes the YAML look prettier.
836
- copy_hash_value(target, source, path + [k])
837
- when ::Array
838
- # also supporting arrays of names at top level or under a hash.
839
- v.each { |item| copy_hash_value(target, source, path + [item]) }
840
- when ::Hash
841
- # recursion.
842
- recursive_selective_hash_copy(target, source, v, path + [k])
846
+ # body is either nil, empty or was not parsable; insert the checksum
847
+ # of the original body.
848
+ case @body
849
+ when nil, '', ' '
850
+ significant_data[:body_checksum] = 'empty'
851
+ else
852
+ significant_data[:body_checksum] = ::Digest::MD5.hexdigest(@body)
853
+ end
854
+ else
855
+ # body was parsed but no single element was considered significant.
856
+ # use the parsed body so that it can be 'normalized' in sorted order.
857
+ significant_data[:body] = body_value
843
858
  end
859
+ else
860
+ raise ::NotImplementedError,
861
+ "Unexpected significant type: #{type.inspect}"
844
862
  end
845
- target
863
+ true
846
864
  end
847
865
 
848
- # copies a single value between hashes by path.
849
- def copy_hash_value(target, source, path)
850
- value = ::RightSupport::Data::HashTools.deep_get(source, path)
851
- ::RightSupport::Data::HashTools.deep_set!(target, path, value)
852
- true
866
+ # Recursively selects values from source.
867
+ #
868
+ # @param [Object] source for selection
869
+ # @param [Object] selections to perform
870
+ # @param [Array] log_path as array of keys or indexes for logging only
871
+ #
872
+ # @return [Object] target of same type as source
873
+ #
874
+ def recursive_selective_hash_copy(source, selections, log_path)
875
+ return nil if source.nil?
876
+ result = nil
877
+ case selections
878
+ when nil, true
879
+ # hash to nil or true; nil means that user configured by using flat
880
+ # hashes instead of arrays, which is a style thing that makes the YAML
881
+ # look prettier. true means explictly consider all elements at current
882
+ # key to be significant.
883
+ result = ::RightSupport::Data::HashTools.deep_clone2(source)
884
+ when false
885
+ # explicitly declared nothing at current level to be significant.
886
+ when ::Array
887
+ # supporting special case of an array of all key names as a list of
888
+ # significant hash key/values.
889
+ if selections.all? { |item| item.is_a?(::String) || item.is_a?(::Symbol) }
890
+ # select hash values by listed keys.
891
+ unless source.kind_of?(::Hash)
892
+ msg = 'Mismatched significant qualifier with source type at ' +
893
+ "#{log_path.join('/')}: #{source.class}"
894
+ raise ::ConfigurationError, msg
895
+ end
896
+ selections.each do |k|
897
+ source_value = source[k]
898
+ unless source_value.nil?
899
+ result ||= ::RightSupport::Data::Mash.new
900
+ result[k] = ::RightSupport::Data::HashTools.deep_clone2(source_value)
901
+ end
902
+ end
903
+ else
904
+ # select specific hash elements of array with specific significance.
905
+ # if a particular element has no siginificance then its selector can
906
+ # be a literal false or else it can fall off the end of the listed
907
+ # selectors.
908
+ unless source.kind_of?(::Array)
909
+ msg = 'Mismatched significant qualifier with source type at ' +
910
+ "#{log_path.join('/')}: #{source.class}"
911
+ raise ::ConfigurationError, msg
912
+ end
913
+ selections.each_with_index do |item, idx|
914
+ if idx < source.size
915
+ result ||= []
916
+ result << recursive_selective_hash_copy(
917
+ source[idx], item, log_path + ["[#{idx}]"])
918
+ else
919
+ break
920
+ end
921
+ end
922
+ end
923
+ when ::Hash
924
+ unless source.kind_of?(::Hash)
925
+ msg = 'Mismatched significant qualifier with source type at ' +
926
+ "#{log_path.join('/')}: #{source.class}"
927
+ raise ::ConfigurationError, msg
928
+ end
929
+ selections.each do |k, v|
930
+ target = recursive_selective_hash_copy(source[k], v, log_path + [k])
931
+ unless target.nil?
932
+ result ||= ::RightSupport::Data::Mash.new
933
+ result[k] = target
934
+ end
935
+ end
936
+ result
937
+ else
938
+ raise ::ConfigurationError,
939
+ "Unexpected significant value type at #{log_path.join('/')}: #{v.class}"
940
+ end
941
+ result
853
942
  end
854
943
 
855
944
  end # Metadata
@@ -2,4 +2,4 @@ source 'https://rubygems.org'
2
2
 
3
3
  gem 'rack'
4
4
  gem 'rest-client'
5
- gem 'right_support', '>= 2.8.31'
5
+ gem 'right_support', '>= 2.8.38'
@@ -5,7 +5,7 @@ GEM
5
5
  rack (1.5.2)
6
6
  rest-client (1.6.7)
7
7
  mime-types (>= 1.16)
8
- right_support (2.8.31)
8
+ right_support (2.8.38)
9
9
 
10
10
  PLATFORMS
11
11
  ruby
@@ -13,4 +13,4 @@ PLATFORMS
13
13
  DEPENDENCIES
14
14
  rack
15
15
  rest-client
16
- right_support (>= 2.8.31)
16
+ right_support (>= 2.8.38)
@@ -140,16 +140,18 @@ module RightDevelop::Testing::Server::MightApi
140
140
  # load request/response pair to validate.
141
141
  request_file_path = ::File.join(requests_dir, path)
142
142
  response_file_path = ::File.join(responses_dir, path)
143
+ logger.debug("Normalizing request #{request_file_path.inspect} ...")
143
144
  request_data = RightSupport::Data::Mash.new(::YAML.load_file(request_file_path))
144
145
  response_data = RightSupport::Data::Mash.new(::YAML.load_file(response_file_path))
145
146
 
146
147
  # if confing contains unreachable (i.e. no available route) files
147
148
  # then that is ignorable.
148
149
  query_string = request_data[:query]
150
+ path_parent = ::File.dirname(path)
149
151
  uri = METADATA_CLASS.normalize_uri(
150
152
  URI::HTTP.build(
151
153
  host: 'none',
152
- path: (route_path + ::File.dirname(path)),
154
+ path: path_parent == '.' ? route_path : (route_path + path_parent),
153
155
  query: request_data[:query]).to_s)
154
156
 
155
157
  # compute checksum from recorded request metadata.
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: right_develop 3.1.8 ruby lib
5
+ # stub: right_develop 3.1.9 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "right_develop"
9
- s.version = "3.1.8"
9
+ s.version = "3.1.9"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
13
13
  s.authors = ["Tony Spataro"]
14
- s.date = "2014-11-11"
14
+ s.date = "2014-12-01"
15
15
  s.description = "A toolkit of development tools created by RightScale."
16
16
  s.email = "support@rightscale.com"
17
17
  s.executables = ["right_develop"]
@@ -81,7 +81,7 @@ Gem::Specification.new do |s|
81
81
  ]
82
82
  s.homepage = "https://github.com/rightscale/right_develop"
83
83
  s.licenses = ["MIT"]
84
- s.rubygems_version = "2.2.0"
84
+ s.rubygems_version = "2.2.2"
85
85
  s.summary = "Reusable dev & test code."
86
86
 
87
87
  if s.respond_to? :specification_version then
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: right_develop
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.8
4
+ version: 3.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Spataro
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-11-11 00:00:00.000000000 Z
11
+ date: 2014-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: right_support
@@ -249,7 +249,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
249
249
  version: '0'
250
250
  requirements: []
251
251
  rubyforge_project:
252
- rubygems_version: 2.2.0
252
+ rubygems_version: 2.2.2
253
253
  signing_key:
254
254
  specification_version: 4
255
255
  summary: Reusable dev & test code.