spec_forge 0.4.0 → 0.6.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +4 -0
  3. data/CHANGELOG.md +145 -1
  4. data/README.md +49 -638
  5. data/flake.lock +3 -3
  6. data/flake.nix +8 -2
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +141 -12
  9. data/lib/spec_forge/attribute/faker.rb +64 -15
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +188 -13
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -20
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +168 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +79 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/init.rb +11 -1
  27. data/lib/spec_forge/cli/new.rb +54 -3
  28. data/lib/spec_forge/cli/run.rb +20 -0
  29. data/lib/spec_forge/cli.rb +16 -5
  30. data/lib/spec_forge/configuration.rb +94 -25
  31. data/lib/spec_forge/context/callbacks.rb +91 -0
  32. data/lib/spec_forge/context/global.rb +72 -0
  33. data/lib/spec_forge/context/store.rb +148 -0
  34. data/lib/spec_forge/context/variables.rb +91 -0
  35. data/lib/spec_forge/context.rb +36 -0
  36. data/lib/spec_forge/core_ext/rspec.rb +24 -4
  37. data/lib/spec_forge/error.rb +267 -113
  38. data/lib/spec_forge/factory.rb +33 -14
  39. data/lib/spec_forge/filter.rb +87 -0
  40. data/lib/spec_forge/forge.rb +170 -0
  41. data/lib/spec_forge/http/backend.rb +99 -29
  42. data/lib/spec_forge/http/client.rb +23 -13
  43. data/lib/spec_forge/http/request.rb +74 -62
  44. data/lib/spec_forge/http/verb.rb +79 -0
  45. data/lib/spec_forge/http.rb +105 -0
  46. data/lib/spec_forge/loader.rb +254 -0
  47. data/lib/spec_forge/matchers.rb +130 -0
  48. data/lib/spec_forge/normalizer/configuration.rb +24 -11
  49. data/lib/spec_forge/normalizer/constraint.rb +22 -9
  50. data/lib/spec_forge/normalizer/expectation.rb +31 -12
  51. data/lib/spec_forge/normalizer/factory.rb +24 -11
  52. data/lib/spec_forge/normalizer/factory_reference.rb +32 -13
  53. data/lib/spec_forge/normalizer/global_context.rb +88 -0
  54. data/lib/spec_forge/normalizer/spec.rb +39 -16
  55. data/lib/spec_forge/normalizer.rb +255 -41
  56. data/lib/spec_forge/runner/callbacks.rb +246 -0
  57. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  58. data/lib/spec_forge/runner/listener.rb +54 -0
  59. data/lib/spec_forge/runner/metadata.rb +58 -0
  60. data/lib/spec_forge/runner/state.rb +99 -0
  61. data/lib/spec_forge/runner.rb +133 -119
  62. data/lib/spec_forge/spec/expectation/constraint.rb +95 -20
  63. data/lib/spec_forge/spec/expectation.rb +43 -51
  64. data/lib/spec_forge/spec.rb +83 -96
  65. data/lib/spec_forge/type.rb +36 -4
  66. data/lib/spec_forge/version.rb +4 -1
  67. data/lib/spec_forge.rb +161 -76
  68. metadata +20 -5
  69. data/spec_forge/factories/user.yml +0 -4
  70. data/spec_forge/forge_helper.rb +0 -37
  71. data/spec_forge/specs/users.yml +0 -65
@@ -1,54 +1,121 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
+ #
5
+ # Base class for normalizing various data structures in SpecForge
6
+ #
7
+ # The Normalizer validates and standardizes user input from YAML files,
8
+ # ensuring it meets the expected structure and types before processing.
9
+ # It supports default values, type checking, aliases, and nested structures.
10
+ #
11
+ # @example Normalizing a hash
12
+ # normalizer = Normalizer.new("spec", input_hash)
13
+ # output, errors = normalizer.normalize
14
+ #
4
15
  class Normalizer
16
+ #
17
+ # Shared attributes used by the various normalizers
18
+ #
19
+ # @return [Hash<Symbol, Hash>]
20
+ #
5
21
  SHARED_ATTRIBUTES = {
22
+ id: {type: String},
23
+ name: {type: String},
24
+ line_number: {type: Integer},
6
25
  base_url: {
7
26
  type: String,
8
- default: ""
27
+ default: nil
9
28
  },
10
29
  url: {
11
30
  type: String,
12
31
  aliases: %i[path],
13
- default: ""
32
+ default: nil
14
33
  },
15
- http_method: {
34
+ http_verb: {
16
35
  type: String,
17
- aliases: %i[method],
18
- default: ""
36
+ aliases: %i[method http_method],
37
+ default: nil, # Do not default this to "GET". Leave it nil. Seriously.
38
+ validator: lambda do |value|
39
+ valid_verbs = HTTP::Verb::VERBS.values
40
+ return if value.blank? || valid_verbs.include?(value.to_s.upcase)
41
+
42
+ raise Error, "Invalid HTTP verb: #{value}. Valid values are: #{valid_verbs.join(", ")}"
43
+ end
19
44
  },
20
45
  headers: {
21
46
  type: Hash,
22
47
  default: {}
23
48
  },
24
49
  query: {
25
- type: Hash,
50
+ type: [Hash, String],
26
51
  aliases: %i[params],
27
52
  default: {}
28
53
  },
29
54
  body: {
30
- type: Hash,
55
+ type: [Hash, String],
31
56
  aliases: %i[data],
32
57
  default: {}
33
58
  },
34
59
  variables: {
35
- type: Hash,
60
+ type: [Hash, String],
36
61
  default: {}
37
62
  },
38
63
  debug: {
39
64
  type: [TrueClass, FalseClass],
40
65
  default: false,
41
66
  aliases: %i[pry breakpoint]
67
+ },
68
+ callback: {
69
+ type: [String, NilClass],
70
+ validator: lambda do |value|
71
+ return if value.blank?
72
+ return if SpecForge::Callbacks.registered?(value)
73
+
74
+ raise Error::UndefinedCallbackError.new(value, SpecForge::Callbacks.registered_names)
75
+ end
42
76
  }
43
77
  }.freeze
44
78
 
79
+ #
80
+ # Defines the normalized structure for validating and parsing input data
81
+ #
82
+ # Each key represents an attribute with its validation and transformation rules.
83
+ # The structure supports defining:
84
+ # - Expected data type(s)
85
+ # - Default values
86
+ # - Aliases for alternative key names
87
+ # - Optional validation logic
88
+ # - Nested sub-structures
89
+ #
90
+ # @return [Hash] A configuration hash defining attribute validation rules
91
+ #
92
+ # @example Basic structure definition
93
+ # STRUCTURE = {
94
+ # name: {
95
+ # type: String, # Must be a String
96
+ # default: "", # Default to empty string if not provided
97
+ # aliases: [:title] # Allows using 'title' as an alternative key
98
+ # },
99
+ # age: {
100
+ # type: Integer, # Must be an Integer
101
+ # default: 0 # Default to 0 if not provided
102
+ # }
103
+ # }
104
+ #
105
+ # @see Normalizer
106
+ #
45
107
  STRUCTURE = {}
46
108
 
47
109
  class << self
48
110
  #
49
111
  # Raises any errors collected by the block
50
112
  #
51
- # @raises InvalidStructureError
113
+ # @yield Block that returns [output, errors]
114
+ # @yieldreturn [Array<Object, Set>] The result and any errors
115
+ #
116
+ # @return [Object] The normalized output if successful
117
+ #
118
+ # @raise [Error::InvalidStructureError] If any errors were encountered
52
119
  #
53
120
  # @private
54
121
  #
@@ -62,7 +129,7 @@ module SpecForge
62
129
  errors << e
63
130
  end
64
131
 
65
- raise InvalidStructureError.new(errors) if errors.size > 0
132
+ raise Error::InvalidStructureError.new(errors) if errors.size > 0
66
133
 
67
134
  output
68
135
  end
@@ -70,6 +137,8 @@ module SpecForge
70
137
  #
71
138
  # Returns a default version of this normalizer
72
139
  #
140
+ # @return [Hash] Default structure with default values
141
+ #
73
142
  # @private
74
143
  #
75
144
  def default
@@ -77,7 +146,14 @@ module SpecForge
77
146
  end
78
147
  end
79
148
 
80
- attr_reader :label, :input, :structure
149
+ # @return [String] A label that describes the data itself
150
+ attr_reader :label
151
+
152
+ # @return [Hash] The data to normalize
153
+ attr_reader :input
154
+
155
+ # @return [Hash] The structure to normalize the data to
156
+ attr_reader :structure
81
157
 
82
158
  #
83
159
  # Creates a normalizer for normalizing Hash data based on a structure
@@ -86,6 +162,8 @@ module SpecForge
86
162
  # @param input [Hash] The data to normalize
87
163
  # @param structure [Hash] The structure to normalize the data to
88
164
  #
165
+ # @return [Normalizer] A new normalizer instance
166
+ #
89
167
  def initialize(label, input, structure: self.class::STRUCTURE)
90
168
  @label = label
91
169
  @input = input
@@ -93,68 +171,88 @@ module SpecForge
93
171
  end
94
172
 
95
173
  #
96
- # Normalizes the data and returns the result
174
+ # Normalizes the data according to the defined structure
97
175
  #
98
- # @return [Hash] The normalized data
176
+ # @return [Array<Hash, Set>] The normalized data and any errors
99
177
  #
100
178
  def normalize
101
- normalize_to_structure
179
+ case input
180
+ when Hash
181
+ normalize_hash
182
+ when Array
183
+ normalize_array
184
+ end
102
185
  end
103
186
 
104
187
  #
105
188
  # Returns a hash with the default structure
106
189
  #
107
- # @return [Hash]
190
+ # @return [Hash] A hash with default values for all structure keys
108
191
  #
109
192
  def default
110
- structure.transform_values do |value|
111
- if value.key?(:default)
112
- value[:default].dup
113
- elsif value[:type] == Integer # Can't call new on int
114
- 0
115
- elsif value[:type] == Proc # Sameeee
116
- -> {}
117
- else
118
- value[:type].new
119
- end
193
+ structure.each_with_object({}) do |(key, value), hash|
194
+ hash[key] =
195
+ if value.key?(:default)
196
+ default = value[:default]
197
+ next if default.nil?
198
+
199
+ default.dup
200
+ elsif value[:type] == Integer # Can't call new on int
201
+ 0
202
+ elsif value[:type] == Proc # Sameeee
203
+ -> {}
204
+ else
205
+ value[:type].new
206
+ end
120
207
  end
121
208
  end
122
209
 
123
210
  protected
124
211
 
125
- def normalize_to_structure
212
+ #
213
+ # Normalizes the input hash according to the structure definition
214
+ #
215
+ # @return [Array<Hash, Set>] Normalized hash and any errors
216
+ #
217
+ # @private
218
+ #
219
+ def normalize_hash
126
220
  output, errors = {}, Set.new
127
221
 
128
222
  structure.each do |key, attribute|
129
223
  type_class = attribute[:type]
130
224
  aliases = attribute[:aliases] || []
131
- sub_structure = attribute[:structure]
132
225
  default = attribute[:default]
133
- required = !attribute.key?(:default)
226
+
227
+ has_default = attribute.key?(:default)
228
+ nilable = has_default && default.nil?
134
229
 
135
230
  # Get the value
136
231
  value = value_from_keys(input, [key] + aliases)
232
+ next if nilable && value.nil?
137
233
 
138
234
  # Default the value if needed
139
- value = default.dup if !required && value.nil?
235
+ value = default.dup if has_default && value.nil?
140
236
 
141
237
  # Type + existence check
142
238
  if !valid_class?(value, type_class)
143
- raise InvalidTypeError.new(value, type_class, for: "\"#{key}\" on #{label}")
239
+ for_context = generate_error_label(key, aliases)
240
+
241
+ if (line_number = input[:line_number])
242
+ for_context += " (line #{line_number})"
243
+ end
244
+
245
+ raise Error::InvalidTypeError.new(value, type_class, for: for_context)
144
246
  end
145
247
 
146
- value =
147
- case [value.class, sub_structure.class]
148
- when [Hash, Hash]
149
- new_value, new_errors = self.class
150
- .new(label, value, structure: sub_structure)
151
- .normalize
248
+ # Call the validator if it has one
249
+ attribute[:validator]&.call(value)
152
250
 
153
- errors += new_errors if new_errors.size > 0
154
- new_value
155
- else
156
- value
157
- end
251
+ # Normalize any sub structures
252
+ if (substructure = attribute[:structure])
253
+ new_label = generate_error_label(key, aliases)
254
+ value = normalize_substructure(new_label, value, substructure, errors)
255
+ end
158
256
 
159
257
  # Store
160
258
  output[key] = value
@@ -165,10 +263,30 @@ module SpecForge
165
263
  [output, errors]
166
264
  end
167
265
 
266
+ #
267
+ # Extracts a value from a hash checking multiple keys
268
+ #
269
+ # @param hash [Hash] The hash to extract from
270
+ # @param keys [Array<Symbol, String>] The keys to check
271
+ #
272
+ # @return [Object, nil] The value if found, nil otherwise
273
+ #
274
+ # @private
275
+ #
168
276
  def value_from_keys(hash, keys)
169
277
  hash.find { |k, v| keys.include?(k) }&.second
170
278
  end
171
279
 
280
+ #
281
+ # Checks if a value is of the expected type
282
+ #
283
+ # @param value [Object] The value to check
284
+ # @param expected_type [Class, Array<Class>] The expected type(s)
285
+ #
286
+ # @return [Boolean] Whether the value is of the expected type
287
+ #
288
+ # @private
289
+ #
172
290
  def valid_class?(value, expected_type)
173
291
  if expected_type.instance_of?(Array)
174
292
  expected_type.any? { |type| value.is_a?(type) }
@@ -176,6 +294,102 @@ module SpecForge
176
294
  value.is_a?(expected_type)
177
295
  end
178
296
  end
297
+
298
+ #
299
+ # Generates an error label with information about the key and its aliases
300
+ #
301
+ # Creates a descriptive label for error messages that includes the key name,
302
+ # any aliases it may have, and the context in which it appears.
303
+ #
304
+ # @param key [Symbol, String] The key that caused the error
305
+ # @param aliases [Array<Symbol, String>] Any aliases for the key
306
+ #
307
+ # @return [String] A formatted error label
308
+ #
309
+ # @example
310
+ # generate_error_label(:user_id, [:id, :uid])
311
+ # # => "\"user_id\" (aliases \"id\", \"uid\") in user config"
312
+ #
313
+ def generate_error_label(key, aliases)
314
+ error_label = key.to_s.in_quotes
315
+
316
+ if aliases.size > 0
317
+ aliases = aliases.join_map(", ") { |a| a.to_s.in_quotes }
318
+ error_label += " (aliases #{aliases})"
319
+ end
320
+
321
+ error_label + " in #{label}"
322
+ end
323
+
324
+ #
325
+ # Normalizes a nested substructure within a parent structure
326
+ #
327
+ # Recursively processes nested Hash or Array structures according to
328
+ # their structure definitions, collecting any validation errors.
329
+ #
330
+ # @param new_label [String] The label to use for error messages
331
+ # @param value [Hash, Array] The nested structure to normalize
332
+ # @param substructure [Hash] The structure definition for validation
333
+ # @param errors [Set] A set to collect any encountered errors
334
+ #
335
+ # @return [Hash, Array] The normalized substructure
336
+ #
337
+ # @example
338
+ # value = {name: "Test", age: "25"}
339
+ # substructure = {name: {type: String}, age: {type: Integer}}
340
+ # normalize_substructure("user", value, substructure, Set.new)
341
+ # # => {name: "Test", age: 25}
342
+ #
343
+ def normalize_substructure(new_label, value, substructure, errors)
344
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
345
+
346
+ new_value, new_errors = self.class
347
+ .new(new_label, value, structure: substructure)
348
+ .normalize
349
+
350
+ errors.merge(new_errors) if new_errors.size > 0
351
+ new_value
352
+ end
353
+
354
+ #
355
+ # Normalizes an array according to its structure definition
356
+ #
357
+ # Processes each element in the input array, validating its type and
358
+ # recursively normalizing any nested structures.
359
+ #
360
+ # @return [Array<Object, Set>] A two-element array containing:
361
+ # 1. The normalized array
362
+ # 2. A set of any errors encountered during normalization
363
+ #
364
+ # @example
365
+ # input = [1, "string", 3]
366
+ # structure = {type: Numeric}
367
+ # normalize_array # => [[1, 3], #<Set: {Error}>]
368
+ #
369
+ def normalize_array
370
+ output, errors = [], Set.new
371
+
372
+ input.each_with_index do |value, index|
373
+ type_class = structure[:type]
374
+
375
+ if !valid_class?(value, type_class)
376
+ raise Error::InvalidTypeError.new(value, type_class, for: "index #{index} of #{label}")
377
+ end
378
+
379
+ # Call the validator if it has one
380
+ structure[:validator]&.call(value)
381
+
382
+ if (substructure = structure[:structure])
383
+ value = normalize_substructure("index #{index} of #{label}", value, substructure, errors)
384
+ end
385
+
386
+ output << value
387
+ rescue => e
388
+ errors << e
389
+ end
390
+
391
+ [output, errors]
392
+ end
179
393
  end
180
394
  end
181
395
 
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Runner
5
+ #
6
+ # Manages lifecycle hooks for test execution
7
+ #
8
+ # This class provides callback methods that run at specific points during test execution
9
+ # to prepare the test environment, manage state, and perform cleanup operations. These
10
+ # callbacks integrate with RSpec's test lifecycle and maintain the SpecForge context.
11
+ #
12
+ # @example Running before file callback
13
+ # Callbacks.before_file(forge)
14
+ #
15
+ class Callbacks
16
+ class << self
17
+ #
18
+ # Callback executed before a file's specs are run
19
+ #
20
+ # Initializes global context and sets up any file-level state needed
21
+ # for all specs in the file.
22
+ #
23
+ # @param forge [SpecForge::Forge] The forge representing the current file
24
+ #
25
+ def before_file(forge)
26
+ # Set the global variables
27
+ SpecForge.context.global.set(**forge.global)
28
+
29
+ # Clear the store for this file
30
+ SpecForge.context.store.clear
31
+
32
+ # Start fresh
33
+ State.clear
34
+
35
+ # Run the user's before_file callbacks
36
+ run_user_callbacks(:before_file, file_context(forge))
37
+ end
38
+
39
+ #
40
+ # Callback executed before each spec is run
41
+ #
42
+ # Prepares the context for a specific spec, including loading
43
+ # spec-level variables and configuration.
44
+ #
45
+ # @param forge [SpecForge::Forge] The forge being tested
46
+ # @param spec [SpecForge::Spec] The spec about to be executed
47
+ #
48
+ def before_spec(forge, spec)
49
+ # Prepare the variables for this spec
50
+ SpecForge.context.variables.set(**forge.variables_for_spec(spec))
51
+
52
+ # Clear any "spec" level stored data
53
+ SpecForge.context.store.clear_specs
54
+
55
+ # Run the user's before_spec callbacks
56
+ run_user_callbacks(:before_spec, spec_context(forge, spec))
57
+ end
58
+
59
+ #
60
+ # Callback executed before each expectation is run
61
+ #
62
+ # Prepares variables for the specific expectation and sets up
63
+ # example metadata for error reporting.
64
+ #
65
+ # @param forge [SpecForge::Forge] The forge being tested
66
+ # @param spec [SpecForge::Spec] The spec being tested
67
+ # @param expectation [SpecForge::Spec::Expectation] The expectation about to be evaluated
68
+ # @param example_group [RSpec::Core::ExampleGroup] The current running example group
69
+ # @param example [RSpec::Core::Example] The current example
70
+ #
71
+ def before_expectation(forge, spec, expectation, example_group, example)
72
+ # Store metadata to failure/error messages display the correct information
73
+ Metadata.set_for_example(spec, expectation)
74
+
75
+ # Store state data for callbacks and persisting data into the store
76
+ State.set(
77
+ forge:, spec:, expectation:, example_group:, example:,
78
+ request: example_group.request
79
+ )
80
+
81
+ # Load the variable overlay for this expectation (if one exists)
82
+ SpecForge.context.variables.use_overlay(expectation.id)
83
+
84
+ # Run the user's before_each callbacks
85
+ run_user_callbacks(:before_each, expectation_context(forge, spec, expectation, example))
86
+ end
87
+
88
+ #
89
+ # Handles debug mode for an expectation
90
+ #
91
+ # When debugging is enabled for a spec or expectation, this method
92
+ # creates a debugging environment for inspecting test state.
93
+ #
94
+ # @param forge [SpecForge::Forge] The forge being tested
95
+ # @param spec [SpecForge::Spec] The spec being tested
96
+ # @param expectation [SpecForge::Spec::Expectation] The expectation being evaluated
97
+ # @param example_group [RSpec::Core::ExampleGroup] The current running example group
98
+ #
99
+ def on_debug(forge, spec, expectation, example_group)
100
+ DebugProxy.new(forge, spec, expectation, example_group).call
101
+ end
102
+
103
+ #
104
+ # Callback executed after each expectation is run
105
+ #
106
+ # Performs cleanup and stores results if needed for future reference.
107
+ #
108
+ # @param forge [SpecForge::Forge] The forge being tested
109
+ # @param spec [SpecForge::Spec] The spec being tested
110
+ # @param expectation [SpecForge::Spec::Expectation] The expectation that was evaluated
111
+ # @param example_group [RSpec::Core::ExampleGroup] The current running example group
112
+ # @param example [RSpec::Core::Example] The current example
113
+ #
114
+ def after_expectation(forge, spec, expectation, example_group, example)
115
+ # Note: Let variables on `example_group` have been reset by RSpec at this point.
116
+ # Calling them will result in a new value being returned and memoized.
117
+ # In other words, do not call `example_group.response` in here unless you
118
+ # like potentially duplicating data ;)
119
+ State.persist
120
+
121
+ # Run the user's after_each callbacks
122
+ run_user_callbacks(:after_each, expectation_context(forge, spec, expectation, example))
123
+
124
+ # Clear the state for the next expectation
125
+ State.clear
126
+ end
127
+
128
+ #
129
+ # Callback executed after each spec is ran
130
+ #
131
+ # @param forge [SpecForge::Forge] The forge being tested
132
+ # @param spec [SpecForge::Spec] The spec that was executed
133
+ #
134
+ def after_spec(forge, spec)
135
+ # Run the user's after_spec callbacks
136
+ run_user_callbacks(:after_spec, spec_context(forge, spec))
137
+ end
138
+
139
+ #
140
+ # Callback executed after a file's specs have been ran
141
+ #
142
+ # @param forge [SpecForge::Forge] The forge representing the current file
143
+ #
144
+ def after_file(forge)
145
+ # Run the user's after_file callbacks
146
+ run_user_callbacks(:after_file, file_context(forge))
147
+ end
148
+
149
+ private
150
+
151
+ #
152
+ # Executes user-defined callbacks for a specific lifecycle point
153
+ #
154
+ # Processes the callback_type to extract timing and scope information,
155
+ # adds this metadata to the context, and then triggers all registered
156
+ # callbacks for that type.
157
+ #
158
+ # @param callback_type [Symbol, String] The type of callback to run
159
+ # (:before_file, :after_spec, etc.)
160
+ # @param context [Hash] Context data containing state information for the callback
161
+ #
162
+ # @private
163
+ #
164
+ def run_user_callbacks(callback_type, context)
165
+ callback_timing, callback_scope = callback_type.to_s.split("_")
166
+
167
+ # Adds "before_each", "before", and "each" into the context so callbacks
168
+ # can build logic off of them
169
+ context.merge!(
170
+ callback_type: callback_type.to_s,
171
+ callback_timing:, callback_scope:
172
+ )
173
+
174
+ # Run the callbacks for this type
175
+ SpecForge.context.global.callbacks.run(callback_type, context)
176
+ end
177
+
178
+ #
179
+ # Builds the base context for file-level callbacks
180
+ #
181
+ # @param forge [SpecForge::Forge] The forge representing the file
182
+ #
183
+ # @return [Hash] Basic file context
184
+ #
185
+ # @private
186
+ #
187
+ def file_context(forge)
188
+ {
189
+ forge: forge,
190
+ file_path: forge.metadata[:file_path],
191
+ file_name: forge.metadata[:file_name]
192
+ }
193
+ end
194
+
195
+ #
196
+ # Builds context for spec-level callbacks
197
+ # Includes file context plus spec information
198
+ #
199
+ # @param forge [SpecForge::Forge] The forge representing the file
200
+ # @param spec [SpecForge::Spec] The spec being executed
201
+ #
202
+ # @return [Hash] Context with file and spec information
203
+ #
204
+ # @private
205
+ #
206
+ def spec_context(forge, spec)
207
+ file_context(forge).merge(
208
+ spec: spec,
209
+ spec_name: spec.name,
210
+ variables: SpecForge.context.variables
211
+ )
212
+ end
213
+
214
+ #
215
+ # Builds context for expectation-level callbacks
216
+ # Includes spec context plus expectation information
217
+ #
218
+ # @param forge [SpecForge::Forge] The forge being tested
219
+ # @param spec [SpecForge::Spec] The spec being tested
220
+ # @param expectation [SpecForge::Spec::Expectation] The expectation being evaluated
221
+ # @param example [RSpec::Core::Example] The current example
222
+ #
223
+ # @return [Hash] Context with file, spec and expectation information
224
+ #
225
+ # @private
226
+ #
227
+ def expectation_context(forge, spec, expectation, example)
228
+ example_group = State.current.example_group
229
+
230
+ # Pull this data from the State instead of example group to avoid creating a new value
231
+ request = State.current.request
232
+ response = State.current.response
233
+
234
+ spec_context(forge, spec).merge(
235
+ expectation:,
236
+ expectation_name: expectation.name,
237
+ request:,
238
+ response:,
239
+ example_group:,
240
+ example:
241
+ )
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end