spec_forge 0.5.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 +3 -3
  3. data/CHANGELOG.md +106 -1
  4. data/README.md +34 -22
  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 +91 -14
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  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 +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  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 +166 -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 -22
  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 +22 -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 +21 -8
  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 +27 -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 +132 -123
  62. data/lib/spec_forge/spec/expectation/constraint.rb +91 -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 -48
  71. data/spec_forge/specs/users.yml +0 -65
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Runner
5
+ #
6
+ # Creates a debugging environment during test execution.
7
+ # When a breakpoint is triggered, this provides an interface to inspect
8
+ # the current test state including the request, response, variables, and expectations.
9
+ #
10
+ # By default, this outputs a JSON representation of the current testing context,
11
+ # but it can be customized by configuring SpecForge.configuration.on_debug
12
+ # to use any Ruby debugger (like pry or debug).
13
+ #
14
+ # @example Basic usage in a spec with `debug: true`
15
+ # # In your YAML test:
16
+ # get_users:
17
+ # debug: true
18
+ # path: /users
19
+ # expectations:
20
+ # - expect:
21
+ # status: 200
22
+ #
23
+ # @example Custom debug handler in forge_helper.rb
24
+ # SpecForge.configure do |config|
25
+ # config.on_debug { binding.pry } # Requires 'pry' gem
26
+ # end
27
+ #
28
+ class DebugProxy
29
+ #
30
+ # @return [Proc] The default debugging handler that outputs JSON state information
31
+ #
32
+ def self.default
33
+ -> { puts inspect }
34
+ end
35
+
36
+ # @return [RSpec::Forge] The current Forge that is being tested
37
+ attr_reader :forge
38
+
39
+ # @return [SpecForge::Spec] The current Spec that is being tested
40
+ attr_reader :spec
41
+
42
+ # @return [SpecForge::Spec::Expectation] The current expectation that is being tested
43
+ attr_reader :expectation
44
+
45
+ # @return [RSpec::ExampleGroup] The current RSpec example group
46
+ attr_reader :example_group
47
+
48
+ # @return [RSpec::Example] The current RSpec example that is running
49
+ attr_reader :example
50
+
51
+ # @return [Integer] The expected HTTP status code
52
+ attr_reader :expected_status
53
+
54
+ # @return [Object] The expected response body structure
55
+ attr_reader :expected_json
56
+
57
+ delegate_missing_to :@example_group
58
+
59
+ #
60
+ # Creates a new DebugProxy instance
61
+ #
62
+ # @param forge [SpecForge::Forge] The forge being tested
63
+ # @param spec [SpecForge::Spec] The spec being tested
64
+ # @param expectation [SpecForge::Spec::Expectation] The expectation being tested
65
+ # @param example_group [RSpec::Core::ExampleGroup] The current example group
66
+ #
67
+ # @return [SpecForge::Runner::DebugProxy]
68
+ #
69
+ def initialize(forge, spec, expectation, example_group)
70
+ @callback = SpecForge.configuration.on_debug
71
+
72
+ @forge = forge
73
+ @spec = spec
74
+ @expectation = expectation
75
+ @example_group = example_group
76
+ @example = RSpec.current_example
77
+
78
+ constraints = expectation.constraints
79
+
80
+ @expected_status = constraints.status.resolved
81
+ @expected_json = constraints.json.resolved
82
+ end
83
+
84
+ #
85
+ # Triggers the debugging environment
86
+ #
87
+ # Displays available debugging contexts and executes the configured debug callback.
88
+ # The callback runs in the context of this proxy, giving it access to all helper methods.
89
+ #
90
+ # @return [void]
91
+ #
92
+ def call
93
+ puts <<~STRING
94
+
95
+ Debug triggered for:
96
+ > #{example.metadata[:rerun_file_path]} on line #{expectation.line_number}
97
+
98
+ Available debugging contexts:
99
+ - spec: Current spec details
100
+ - expectation: Current expectation being tested
101
+ - variables: Variables defined for this test
102
+ - global: Global context shared across tests
103
+ - store: Stored data from expectations
104
+
105
+ Request & Response:
106
+ - request: HTTP request details (method, url, headers, body)
107
+ - response: HTTP response with headers, status and body
108
+
109
+ Expectations:
110
+ - expected_status: Expected HTTP status code
111
+ - expected_json: Expected response body structure
112
+
113
+ Matchers:
114
+ - match_status: Matcher used to test status
115
+ - match_json: Matcher used to test response body
116
+
117
+ Helper objects:
118
+ - http_client: The HTTP client used for the request
119
+ - request_data: Raw request configuration data
120
+ - example_group: Current RSpec example group
121
+ - example: Current RSpec example
122
+ - forge: Current file being tested
123
+
124
+ 💡 Pro tips:
125
+ - Type 'self' or 'inspect' for a pretty-printed JSON overview
126
+ - Use 'to_h' for the hash representation
127
+ - Access the shared context with 'SpecForge.context'
128
+ STRING
129
+
130
+ instance_exec(&@callback)
131
+ end
132
+
133
+ ##########################################################################
134
+
135
+ #
136
+ # Returns a hash representation of the global context
137
+ #
138
+ # @return [Hash] The global context with resolved variables
139
+ #
140
+ def global
141
+ @global ||= SpecForge.context.global.to_h
142
+ end
143
+
144
+ #
145
+ # Returns a hash representation of the variables in the current context
146
+ #
147
+ # Includes both spec-level and expectation-level variables combined
148
+ # with values fully resolved.
149
+ #
150
+ # @return [Hash]
151
+ #
152
+ def variables
153
+ @variables ||= SpecForge.context.variables
154
+ end
155
+
156
+ #
157
+ # Returns a hash representation of the store context
158
+ #
159
+ # @return [Hash] The store context
160
+ #
161
+ def store
162
+ @store ||= SpecForge.context.store.to_h
163
+ end
164
+
165
+ ##########################################################################
166
+
167
+ #
168
+ # Returns a hash representation of the test state
169
+ #
170
+ # Includes the spec, expectation, request, response, variables and global context.
171
+ # RSpec matchers are converted to human-readable descriptions.
172
+ #
173
+ # @return [Hash]
174
+ #
175
+ def to_h
176
+ spec_hash = spec.to_h.except(:expectations)
177
+
178
+ expectation_hash = expectation.to_h
179
+ expectation_hash[:expect][:json] = matchers_to_description(expectation_hash[:expect][:json])
180
+
181
+ {
182
+ response: {
183
+ status: response.status,
184
+ body: response.body,
185
+ headers: response.headers
186
+ },
187
+ global:,
188
+ variables:,
189
+ request: request.to_h,
190
+ expectation: expectation_hash,
191
+ spec: spec_hash
192
+ }
193
+ end
194
+
195
+ #
196
+ # Returns a formatted JSON representation of the test state
197
+ #
198
+ # @return [String] Pretty-printed JSON of the test state
199
+ #
200
+ def inspect
201
+ JSON.pretty_generate(to_h)
202
+ end
203
+
204
+ private
205
+
206
+ def matchers_to_description(value)
207
+ return value unless value.is_a?(RSpec::Matchers::BuiltIn::BaseMatcher)
208
+
209
+ value.description
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Runner
5
+ #
6
+ # Listens for RSpec test result notifications and triggers the appropriate callbacks
7
+ #
8
+ # This singleton class receives notifications from RSpec when examples pass or fail,
9
+ # retrieves the current test context, and triggers the appropriate SpecForge callbacks.
10
+ # It acts as a bridge between RSpec's notification system and SpecForge's callback system.
11
+ #
12
+ class Listener
13
+ include Singleton
14
+
15
+ #
16
+ # Handles RSpec notifications for passing examples
17
+ #
18
+ # @param notification [RSpec::Core::Notifications::ExampleNotification]
19
+ # The notification object
20
+ #
21
+ def example_passed(notification)
22
+ trigger_callback
23
+ end
24
+
25
+ #
26
+ # Handles RSpec notifications for failing examples
27
+ #
28
+ # @param notification [RSpec::Core::Notifications::FailedExampleNotification]
29
+ # The notification object
30
+ #
31
+ def example_failed(notification)
32
+ trigger_callback
33
+ end
34
+
35
+ private
36
+
37
+ #
38
+ # Triggers the appropriate SpecForge callback with the complete context
39
+ #
40
+ # Retrieves the current example context stored during the RSpec execution, and passes
41
+ # everything to the appropriate callback.
42
+ #
43
+ # @private
44
+ #
45
+ def trigger_callback
46
+ context = Runner::State.current.to_h.slice(
47
+ :forge, :spec, :expectation, :example_group, :example
48
+ )
49
+
50
+ Runner::Callbacks.after_expectation(*context.values)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Runner
5
+ #
6
+ # Manages metadata for RSpec example groups and examples
7
+ #
8
+ # This class provides methods for setting up correct metadata on RSpec groups
9
+ # and examples, which enables proper error reporting and command-line rerun
10
+ # instructions when tests fail.
11
+ #
12
+ # @example Setting metadata on an example group
13
+ # Metadata.set_for_group(spec, expectation, example_group)
14
+ #
15
+ class Metadata
16
+ class << self
17
+ #
18
+ # Updates the example group metadata for error reporting
19
+ #
20
+ # Sets the file path, line number, and location information in the
21
+ # example group's metadata. This ensures that RSpec can generate the
22
+ # proper command to rerun the failing tests.
23
+ #
24
+ # @param spec [SpecForge::Spec] The spec being tested
25
+ # @param expectation [SpecForge::Spec::Expectation] The expectation being evaluated
26
+ # @param example_group [RSpec::Core::ExampleGroup] The example group to update
27
+ #
28
+ def set_for_group(spec, expectation, example_group)
29
+ metadata = {
30
+ file_path: spec.file_path,
31
+ absolute_file_path: spec.file_path,
32
+ line_number: expectation.line_number,
33
+ location: spec.file_path,
34
+ rerun_file_path: "#{spec.file_name}:#{spec.name}:\"#{expectation.name}\""
35
+ }
36
+
37
+ example_group.metadata.merge!(metadata)
38
+ end
39
+
40
+ #
41
+ # Updates the current example's metadata for error reporting
42
+ #
43
+ # Sets location information on the currently running example.
44
+ # This helps RSpec generate more accurate error messages when
45
+ # an exception occurs during test execution.
46
+ #
47
+ # @param spec [SpecForge::Spec] The spec being tested
48
+ # @param expectation [SpecForge::Spec::Expectation] The expectation being evaluated
49
+ #
50
+ def set_for_example(spec, expectation)
51
+ metadata = {location: "#{spec.file_path}:#{expectation.line_number}"}
52
+
53
+ RSpec.current_example.metadata.merge!(metadata)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Runner
5
+ #
6
+ # Maintains test execution state to prevent duplicate HTTP requests
7
+ #
8
+ # This singleton class captures and preserves references to the current test context,
9
+ # including request and response objects that would otherwise be re-evaluated
10
+ # when accessed after RSpec clears its memoized variables. It solves a specific
11
+ # issue where accessing response data in after_expectation callbacks would
12
+ # trigger duplicate HTTP requests.
13
+ #
14
+ class State < Struct.new(
15
+ :forge, :spec, :expectation, :example_group, :example, :response, :request
16
+ )
17
+ include Singleton
18
+
19
+ #
20
+ # Returns the singleton instance representing the current test state
21
+ #
22
+ # @return [State] The current state instance
23
+ #
24
+ def self.current
25
+ instance
26
+ end
27
+
28
+ #
29
+ # Updates multiple attributes of the state at once
30
+ #
31
+ # @param attributes [Hash] A hash mapping attribute names to values
32
+ #
33
+ def self.set(attributes)
34
+ attributes.each do |key, value|
35
+ instance[key] = value
36
+ end
37
+ end
38
+
39
+ #
40
+ # Persists the current state to the context store if needed
41
+ #
42
+ # Only runs if the current expectation has a store_as directive
43
+ #
44
+ def self.persist
45
+ return unless instance.expectation.store_as?
46
+
47
+ instance.persist_to_store
48
+ end
49
+
50
+ #
51
+ # Clears all state attributes
52
+ #
53
+ def self.clear
54
+ instance.clear
55
+ end
56
+
57
+ ##########################################################################
58
+
59
+ #
60
+ # Clears all attributes in the state
61
+ #
62
+ def clear
63
+ members.each { |key| self[key] = nil }
64
+ end
65
+
66
+ #
67
+ # Persists the current test execution data to the context store
68
+ #
69
+ # Handles scope determination and stores request/response data
70
+ # for later access via the store attribute
71
+ #
72
+ def persist_to_store
73
+ id = expectation.store_as
74
+ scope = :file
75
+
76
+ # Remove the file prefix if it was explicitly provided
77
+ id = id.delete_prefix("file.") if id.start_with?("file.")
78
+
79
+ # Change scope to spec if desired
80
+ if id.start_with?("spec.")
81
+ id = id.delete_prefix("spec.")
82
+ scope = :spec
83
+ end
84
+
85
+ SpecForge.context.store.set(
86
+ id,
87
+ scope:,
88
+ request: request&.to_h,
89
+ variables: SpecForge.context.variables.deep_dup,
90
+ response: {
91
+ headers: response&.headers,
92
+ status: response&.status,
93
+ body: response&.body
94
+ }
95
+ )
96
+ end
97
+ end
98
+ end
99
+ end