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
@@ -1,62 +1,134 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
+ #
5
+ # Handles the execution of specs through RSpec
6
+ # Converts SpecForge specs into RSpec examples and runs them
7
+ #
4
8
  class Runner
5
9
  class << self
6
10
  #
7
- # Runs any specs
11
+ # Defines RSpec examples for a collection of forges
12
+ # Creates the test structure that will be executed
13
+ #
14
+ # @param forges [Array<Forge>] The forges to define as RSpec examples
15
+ #
16
+ def define(forges)
17
+ forges.each do |forge|
18
+ define_forge(forge)
19
+ end
20
+ end
21
+
22
+ #
23
+ # Runs the defined RSpec examples
24
+ # Executes the tests after they've been defined
8
25
  #
9
26
  def run
10
- # Allows me to modify the error backtrace reporting within rspec
11
- RSpec.configuration.instance_variable_set(:@backtrace_formatter, BacktraceFormatter)
27
+ prepare_for_run
12
28
 
13
- RSpec::Core::Runner.disable_autorun!
14
- RSpec::Core::Runner.run([], $stderr, $stdout)
29
+ ARGV.clear
30
+ RSpec::Core::Runner.invoke
15
31
  end
16
32
 
17
33
  #
18
- # Defines a spec with RSpec
34
+ # Defines RSpec examples for a specific forge
35
+ # Creates the test structure for a single forge file
19
36
  #
20
- # @param spec_forge [Spec] The spec to define
37
+ # @param forge [Forge] The forge to define
21
38
  #
22
- def define_spec(spec_forge)
23
- runner_forge = self
24
-
25
- RSpec.describe(spec_forge.name) do
26
- spec_forge.expectations.each do |expectation|
27
- # Define the example group
28
- describe(expectation.name) do
29
- # Set up the class metadata for error reporting
30
- runner_forge.set_group_metadata(self, spec_forge, expectation)
31
-
32
- constraints = expectation.constraints
33
-
34
- let!(:expected_status) { constraints.status.resolve }
35
- let!(:expected_json) { constraints.json.resolve }
36
- let!(:expected_json_class) { expected_json&.expected.class }
37
-
38
- before do
39
- # Ensure all variables are called and resolved, in case they are not referenced
40
- expectation.variables.resolve
41
-
42
- # Set up the example metadata for error reporting
43
- runner_forge.set_example_metadata(spec_forge, expectation)
44
- end
45
-
46
- subject(:response) { expectation.http_client.call }
47
-
48
- it do
49
- if spec_forge.debug? || expectation.debug?
50
- runner_forge.handle_debug(expectation, self)
51
- end
52
-
53
- # Status check
54
- expect(response.status).to eq(expected_status)
55
-
56
- # JSON check
57
- if expected_json
58
- expect(response.body).to be_kind_of(expected_json_class)
59
- expect(response.body).to expected_json
39
+ def define_forge(forge)
40
+ # This is just like writing a normal RSpec test, except with loops ;)
41
+ RSpec.describe(forge.name) do
42
+ # Callback for the file
43
+ before(:context) { Callbacks.before_file(forge) }
44
+ after(:context) { Callbacks.after_file(forge) }
45
+
46
+ # Specs
47
+ forge.specs.each do |spec|
48
+ # Describe the spec
49
+ describe(spec.name) do
50
+ # Request data is for the spec and contains the base and overlays
51
+ let!(:request_data) { forge.request[spec.id] }
52
+
53
+ # The HTTP client for the spec
54
+ let!(:http_client) { HTTP::Client.new(**request_data[:base]) }
55
+
56
+ # Callback for the spec
57
+ before(:context) { Callbacks.before_spec(forge, spec) }
58
+ after(:context) { Callbacks.after_spec(forge, spec) }
59
+
60
+ # Expectations
61
+ spec.expectations.each do |expectation|
62
+ # Onto the actual expectation itself
63
+ describe(expectation.name) do
64
+ # Set metadata for the example group for error reporting
65
+ Metadata.set_for_group(spec, expectation, self)
66
+
67
+ # Lazily load the constraints
68
+ let(:constraints) { expectation.constraints.as_matchers }
69
+
70
+ let(:match_status) { constraints[:status] }
71
+ let(:match_json) { constraints[:json] }
72
+ let(:match_json_class) { be_kind_of(match_json.class) }
73
+
74
+ # The request for the test itself. Overlays the expectation's data if it exists
75
+ let(:request) do
76
+ request = request_data[:base]
77
+
78
+ if (overlay = request_data[:overlay][expectation.id])
79
+ request = request.deep_merge(overlay)
80
+ end
81
+
82
+ HTTP::Request.new(**request)
83
+ end
84
+
85
+ # The Faraday response
86
+ subject(:response) { http_client.call(request) }
87
+
88
+ # Callbacks for the expectation
89
+ before :each do
90
+ Callbacks.before_expectation(
91
+ forge, spec, expectation, self, RSpec.current_example
92
+ )
93
+ end
94
+
95
+ # The 'after_expectation' callback is handled by Listener due to RSpec not
96
+ # reporting the example's status until after the describe block has finished.
97
+ after :each do
98
+ # However, the downside about having the callback triggered later is that RSpec
99
+ # will have reset the memoized let variables back to nil.
100
+ # This causes an issue when an expectation goes to store the state, it will end
101
+ # up re-calling the various variables and triggering another HTTP request.
102
+ # Since the variables are still memoized in this hook, it is the perfect
103
+ # time to store the referenced to them.
104
+ State.set(response:)
105
+ end
106
+
107
+ # The test itself
108
+ it(expectation.constraints.description) do
109
+ if spec.debug? || expectation.debug?
110
+ Callbacks.on_debug(forge, spec, expectation, self)
111
+ end
112
+
113
+ # Status check
114
+ expect(response.status).to match_status
115
+
116
+ # JSON check
117
+ if match_json.present?
118
+ expect(response.body).to match_json_class
119
+
120
+ case match_json
121
+ when Hash
122
+ # Check per key for easier debugging
123
+ match_json.each do |key, matcher|
124
+ expect(response.body).to have_key(key)
125
+ expect(response.body[key]).to matcher
126
+ end
127
+ else
128
+ expect(response.body).to match_json
129
+ end
130
+ end
131
+ end
60
132
  end
61
133
  end
62
134
  end
@@ -64,87 +136,24 @@ module SpecForge
64
136
  end
65
137
  end
66
138
 
67
- # @private
68
- def handle_debug(...)
69
- DebugProxy.new(...).call
70
- end
71
-
72
- # @private
73
- def set_group_metadata(context, spec, expectation)
74
- metadata = {
75
- file_path: spec.file_path,
76
- absolute_file_path: spec.file_path,
77
- line_number: spec.line_number,
78
- location: spec.file_path,
79
- rerun_file_path: "#{spec.file_name}:#{spec.name}:\"#{expectation.name}\""
80
- }
81
-
82
- context.metadata.merge!(metadata)
83
- end
84
-
85
- # @private
86
- def set_example_metadata(spec, expectation)
87
- # This is needed when an error raises in an example
88
- metadata = {location: "#{spec.file_path}:#{spec.line_number}"}
139
+ private
89
140
 
90
- RSpec.current_example.metadata.merge!(metadata)
91
- end
92
- end
93
-
94
- ################################################################################################
95
-
96
- class DebugProxy
97
- def self.default
98
- -> { puts inspect }
99
- end
100
-
101
- attr_reader :expectation, :variables, :expected_status, :expected_json, :request, :response
102
-
103
- def initialize(expectation, spec_context)
104
- @callback = SpecForge.configuration.on_debug
105
-
106
- @expected_status = spec_context.expected_status
107
- @expected_json = spec_context.expected_json
108
-
109
- @request = expectation.http_client.request
110
- @response = spec_context.response
111
-
112
- @variables = expectation.variables
113
- @expectation = expectation
114
- end
115
-
116
- def call
117
- puts <<~STRING
118
-
119
- Debug triggered for: #{expectation.name}
120
-
121
- Available methods:
122
- - expectation: Full expectation context
123
- - variables: Current variable definitions
124
- - expected_status: Expected HTTP status code (#{expected_status})
125
- - expected_json: Expected response body
126
- - expected_json_class: Expected response body class
127
- - request: HTTP request details (method, url, headers, body)
128
- - response: HTTP response
129
-
130
- Tip: Type 'self' for a JSON overview of the current state
131
- Individual methods return full object details for advanced debugging
132
- STRING
133
-
134
- instance_exec(&@callback)
135
- end
136
-
137
- def inspect
138
- hash = expectation.to_h
139
-
140
- hash[:response] = {
141
- headers: response.headers,
142
- status: response.status,
143
- body: response.body
144
- }
141
+ def prepare_for_run
142
+ # Allows modifying the error backtrace reporting within rspec
143
+ RSpec.configuration.instance_variable_set(:@backtrace_formatter, BacktraceFormatter)
145
144
 
146
- JSON.pretty_generate(hash)
145
+ # Listen for passed/failed events to trigger the "after_each" callback
146
+ RSpec.configuration.reporter.register_listener(
147
+ Listener.instance,
148
+ :example_passed, :example_failed
149
+ )
147
150
  end
148
151
  end
149
152
  end
150
153
  end
154
+
155
+ require_relative "runner/callbacks"
156
+ require_relative "runner/debug_proxy"
157
+ require_relative "runner/listener"
158
+ require_relative "runner/metadata"
159
+ require_relative "runner/state"
@@ -4,42 +4,113 @@ module SpecForge
4
4
  class Spec
5
5
  class Expectation
6
6
  #
7
- # Represents the "expect" hash
7
+ # Represents the expected response constraints for an expectation
8
+ #
9
+ # A Constraint defines what the API response should look like,
10
+ # including status code and body content with support for matchers.
11
+ #
12
+ # @example In code
13
+ # constraint = Constraint.new(
14
+ # status: 200,
15
+ # json: {name: "matcher.eq" => "John"}
16
+ # )
8
17
  #
9
18
  class Constraint < Data.define(:status, :json) # :xml, :html
10
19
  #
11
- # Creates a new Constraint
20
+ # Creates a new constraint
12
21
  #
13
- # @param status [Integer] The expected HTTP status code
22
+ # @param status [Integer, String] The expected HTTP status code, or reference to one
14
23
  # @param json [Hash, Array] The expected JSON with matchers
15
24
  #
16
- def initialize(status:, json:)
17
- super(status:, json: convert_to_matchers(json))
25
+ # @return [Constraint] A new constraint instance
26
+ #
27
+ def initialize(status:, json: {})
28
+ super(
29
+ status: Attribute.from(status),
30
+ json: Attribute.from(json)
31
+ )
18
32
  end
19
33
 
34
+ #
35
+ # Converts the constraint to a hash with resolved values
36
+ #
37
+ # @return [Hash] Hash representation with resolved values
38
+ #
20
39
  def to_h
21
40
  super.transform_values(&:resolve)
22
41
  end
23
42
 
24
- private
43
+ #
44
+ # Converts constraints to RSpec matchers for validation
45
+ #
46
+ # Transforms the defined constraints (status and JSON expectations) into
47
+ # appropriate RSpec matchers that can be used in test expectations.
48
+ # This method resolves all values and applies the appropriate matcher
49
+ # conversions to create a complete expectation structure.
50
+ #
51
+ # @return [Hash] A hash containing resolved matchers
52
+ #
53
+ # @example
54
+ # constraint = Constraint.new(status: 200, json: {name: "John"})
55
+ # matchers = constraint.as_matchers
56
+ # # => {status: eq(200), json: include("name" => eq("John"))}
57
+ #
58
+ def as_matchers
59
+ {
60
+ status: status.resolve_as_matcher,
61
+ json: resolve_json_matcher
62
+ }
63
+ end
64
+
65
+ #
66
+ # Generates a human-readable description of what this constraint expects in the response
67
+ #
68
+ # Creates a description string for RSpec examples that clearly explains the expected
69
+ # status code and JSON structure. This makes test output more informative and helps
70
+ # developers understand what's being tested at a glance.
71
+ #
72
+ # @return [String] A human-readable description of the constraint expectations
73
+ #
74
+ # @example Status code with JSON object
75
+ # constraint.description
76
+ # # => "is expected to respond with \"200 OK\" and a JSON object that contains keys: \"id\", \"name\""
77
+ #
78
+ # @example Status code with JSON array
79
+ # constraint.description
80
+ # # => "is expected to respond with \"201 Created\" and a JSON array that contains 3 items"
81
+ #
82
+ def description
83
+ description = "is expected to respond with"
84
+
85
+ description += if status.is_a?(Attribute::Literal)
86
+ " #{HTTP.status_code_to_description(status.input).in_quotes}"
87
+ else
88
+ " the expected status code"
89
+ end
25
90
 
26
- def convert_to_matchers(value)
27
- # This makes it easier to check if json was provided
28
- return Attribute.from(nil) if value.blank?
91
+ size = json.size
92
+
93
+ if Type.array?(json)
94
+ description +=
95
+ " and a JSON array that contains #{size} #{"item".pluralize(size)}"
96
+ elsif Type.hash?(json) && size > 0
97
+ keys = json.keys.join_map(", ", &:in_quotes)
98
+
99
+ description +=
100
+ " and a JSON object that contains #{"key".pluralize(size)}: #{keys}"
101
+ end
102
+
103
+ description
104
+ end
105
+
106
+ private
29
107
 
30
- case value
108
+ def resolve_json_matcher
109
+ case json
31
110
  when HashLike
32
- value = value.transform_values { |i| convert_to_matchers(i) }
33
- Attribute.from("matcher.include" => value)
34
- when ArrayLike
35
- value = value.map { |i| convert_to_matchers(i) }
36
- Attribute.from("matcher.contain_exactly" => value)
37
- when Attribute::Regex
38
- Attribute.from("matcher.match" => value)
39
- when Attribute::Literal
40
- Attribute.from("matcher.eq" => value)
111
+ json.transform_values(&:resolve_as_matcher).stringify_keys
41
112
  else
42
- value
113
+ json.resolve_as_matcher
43
114
  end
44
115
  end
45
116
  end
@@ -1,72 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "expectation/constraint"
4
-
5
3
  module SpecForge
6
4
  class Spec
7
- class Expectation
5
+ #
6
+ # Represents a single test expectation within a spec
7
+ #
8
+ # An Expectation defines what should be tested for a specific API request,
9
+ # including the expected status code and response structure.
10
+ #
11
+ # @example YAML representation
12
+ # - name: "Get user successfully"
13
+ # expect:
14
+ # status: 200
15
+ # json:
16
+ # name: kind_of.string
17
+ #
18
+ class Expectation < Data.define(:id, :name, :line_number, :debug, :store_as, :constraints)
19
+ #
20
+ # @return [Boolean] True if debugging is enabled
21
+ #
8
22
  attr_predicate :debug
9
23
 
10
- attr_reader :name, :variables, :constraints, :http_client
24
+ #
25
+ # @return [Boolean] True if store_as is set
26
+ #
27
+ attr_predicate :store_as
11
28
 
12
29
  #
13
- # Creates a new Expectation
30
+ # Creates a new expectation with constraints
14
31
  #
15
- # @param input [Hash] A hash containing the various attributes to control the expectation
16
- # @param name [String] The name of the expectation
32
+ # @param id [String] Unique identifier
33
+ # @param name [String] Human-readable name
34
+ # @param line_number [Integer] Line number in source
35
+ # @param debug [Boolean] Whether to enable debugging
36
+ # @param store_as [String] Unique Context::Store identifier
37
+ # @param expect [Hash] Expected constraints
17
38
  #
18
- def initialize(input, global_options: {})
19
- # This allows defining spec level attributes that can be overwritten by the expectation
20
- input = Attribute.from(Configuration.overlay_options(global_options, input))
21
-
22
- load_debug(input)
23
- load_variables(input)
24
-
25
- # Must be after load_variables
26
- load_constraints(input)
27
-
28
- @http_client = HTTP::Client.new(
29
- variables:, **input.except(:name, :variables, :expect, :debug)
30
- )
39
+ # @return [Expectation] A new expectation instance
40
+ #
41
+ def initialize(id:, name:, line_number:, debug:, store_as:, expect:)
42
+ constraints = Constraint.new(**expect)
31
43
 
32
- # Must be after http_client
33
- load_name(input)
44
+ super(id:, name:, line_number:, debug:, store_as:, constraints:)
34
45
  end
35
46
 
47
+ #
48
+ # Converts the expectation to a hash representation
49
+ #
50
+ # @return [Hash] Hash representation
51
+ #
36
52
  def to_h
37
53
  {
38
54
  name:,
39
- debug: debug?,
40
- variables: variables.resolve,
41
- request: http_client.request.to_h,
42
- constraints: constraints.to_h
55
+ line_number:,
56
+ debug:,
57
+ expect: constraints.to_h
43
58
  }
44
59
  end
45
-
46
- private
47
-
48
- def load_name(input)
49
- # GET /users
50
- @name = "#{http_client.request.http_verb.upcase} #{http_client.request.url}"
51
-
52
- # GET /users - Returns a 404
53
- if (name = input[:name].resolve.presence)
54
- @name += " - #{name}"
55
- end
56
- end
57
-
58
- def load_variables(input)
59
- @variables = Attribute.bind_variables(input[:variables], input[:variables])
60
- end
61
-
62
- def load_debug(input)
63
- @debug = input[:debug].resolve
64
- end
65
-
66
- def load_constraints(input)
67
- constraints = Attribute.bind_variables(input[:expect], variables)
68
- @constraints = Constraint.new(**constraints)
69
- end
70
60
  end
71
61
  end
72
62
  end
63
+
64
+ require_relative "expectation/constraint"