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
@@ -4,39 +4,114 @@ 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
14
- # @param json [Hash] The expected JSON with matchers
22
+ # @param status [Integer, String] The expected HTTP status code, or reference to one
23
+ # @param json [Hash, Array] The expected JSON with matchers
15
24
  #
16
- def initialize(status:, json:)
17
- super(status:, json: normalize_hash(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
 
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
90
+
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
+
24
106
  private
25
107
 
26
- def normalize_hash(hash)
27
- hash =
28
- hash.transform_values do |attribute|
29
- case attribute
30
- when Attribute::Regex
31
- Attribute.from("matcher.match" => attribute.resolve)
32
- when Attribute::Literal
33
- Attribute.from("matcher.eq" => attribute.resolve)
34
- else
35
- attribute
36
- end
37
- end
38
-
39
- Attribute.from(hash)
108
+ def resolve_json_matcher
109
+ case json
110
+ when HashLike
111
+ json.transform_values(&:resolve_as_matcher).stringify_keys
112
+ else
113
+ json.resolve_as_matcher
114
+ end
40
115
  end
41
116
  end
42
117
  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"
@@ -1,126 +1,113 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "spec/expectation"
4
-
5
3
  module SpecForge
4
+ #
5
+ # Represents a test specification in SpecForge
6
+ #
7
+ # A Spec contains one or more Expectations and defines the base configuration
8
+ # for those expectations. It maps directly to a test defined in YAML.
9
+ #
10
+ # @example YAML representation
11
+ # get_users:
12
+ # path: /users
13
+ # expectations:
14
+ # - expect:
15
+ # status: 200
16
+ #
6
17
  class Spec
7
18
  #
8
- # Loads and defines specs with the runner. Specs can be filtered using the optional parameters
19
+ # @return [Boolean] True if debugging is enabled
9
20
  #
10
- # @param file_name [String, nil] The name of the file without the extension.
11
- # @param spec_name [String, nil] The name of the spec in a yaml file
12
- # @param expectation_name [String, nil] The name of the expectation for a spec.
21
+ attr_predicate :debug
22
+
13
23
  #
14
- # @return [Array<Spec>]
24
+ # Unique identifier for this spec
15
25
  #
16
- def self.load_and_define(file_name: nil, spec_name: nil, expectation_name: nil)
17
- specs = load_from_files
18
-
19
- filter_specs(specs, file_name:, spec_name:, expectation_name:)
20
-
21
- # Announce if we're using a filter
22
- if file_name
23
- filter = {file_name:, spec_name:, expectation_name:}.delete_if { |k, v| v.blank? }
24
- filter.stringify_keys!
25
- puts "Using filter: #{filter}"
26
- end
27
-
28
- specs.each(&:define)
29
- end
26
+ # @return [String] The spec ID
27
+ #
28
+ attr_reader :id
30
29
 
31
30
  #
32
- # Loads any specs defined in the spec files. A single file can contain one or more specs
31
+ # Human-readable name for this spec
33
32
  #
34
- # @return [Array<Spec>] An array of specs that were loaded.
33
+ # @return [String] The spec name
35
34
  #
36
- def self.load_from_files
37
- path = SpecForge.forge.join("specs")
38
- specs = []
39
-
40
- Dir[path.join("**/*.yml")].each do |file_path|
41
- content = File.read(file_path)
42
- hash = YAML.load(content).deep_symbolize_keys
43
-
44
- hash.each do |spec_name, spec_hash|
45
- line_number = content.lines.index { |line| line.start_with?("#{spec_name}:") }
46
-
47
- spec_hash[:name] = spec_name.to_s
48
- spec_hash[:file_path] = file_path
49
- spec_hash[:file_name] = file_path.delete_prefix("#{path}/").delete_suffix(".yml")
50
- spec_hash[:line_number] = line_number ? line_number + 1 : -1
35
+ attr_reader :name
51
36
 
52
- specs << new(**spec_hash)
53
- end
54
- end
55
-
56
- specs
57
- end
58
-
59
- # @private
60
- def self.filter_specs(specs, file_name: nil, spec_name: nil, expectation_name: nil)
61
- # Guard against invalid partial filters
62
- if expectation_name && spec_name.blank?
63
- raise ArgumentError, "The spec's name is required when filtering by an expectation's name"
64
- end
65
-
66
- if spec_name && file_name.blank?
67
- raise ArgumentError, "The spec's filename is required when filtering by a spec's name"
68
- end
69
-
70
- specs.select! { |spec| spec.file_name == file_name } if file_name
71
- specs.select! { |spec| spec.name == spec_name } if spec_name
72
-
73
- if expectation_name
74
- specs.each do |spec|
75
- spec.expectations.select! { |expectation| expectation.name == expectation_name }
76
- end
77
- end
37
+ #
38
+ # Absolute path to the file containing this spec
39
+ #
40
+ # @return [String] The file path
41
+ #
42
+ attr_reader :file_path
78
43
 
79
- specs
80
- end
44
+ #
45
+ # Base name of the file without path or extension
46
+ #
47
+ # @return [String] The file name
48
+ #
49
+ attr_reader :file_name
81
50
 
82
- ############################################################################
51
+ #
52
+ # Whether to enable debugging for this spec
53
+ #
54
+ # @return [Boolean] Debug flag
55
+ #
56
+ attr_reader :debug
83
57
 
84
- attr_predicate :debug
58
+ #
59
+ # Line number in the source file where this spec is defined
60
+ #
61
+ # @return [Integer] The line number
62
+ #
63
+ attr_reader :line_number
85
64
 
86
- attr_reader :name, :file_path, :file_name, :line_number, :expectations
65
+ #
66
+ # The expectations to test for this spec
67
+ #
68
+ # @return [Array<Expectation>] The expectations
69
+ #
70
+ attr_accessor :expectations
87
71
 
88
72
  #
89
- # Creates a Spec based on the input
73
+ # Creates a new spec instance
74
+ #
75
+ # @param id [String] Unique identifier
76
+ # @param name [String] Human-readable name
77
+ # @param file_path [String] Absolute path to source file
78
+ # @param file_name [String] Base name of file
79
+ # @param debug [Boolean] Whether to enable debugging
80
+ # @param line_number [Integer] Line number in source
81
+ # @param expectations [Array<Hash>] Expectation configurations
90
82
  #
91
- # @param name [String] The identifier for this spec
92
- # @param file_path [String] The path where this spec is defined
93
- # @param **input [Hash] Any attributes related to the spec, including expectations
94
- # See Normalizer::Spec
83
+ # @return [Spec] A new spec instance
95
84
  #
96
- def initialize(name:, file_path:, file_name:, line_number:, **input)
85
+ def initialize(id:, name:, file_path:, file_name:, debug:, line_number:, expectations:)
86
+ @id = id
97
87
  @name = name
98
88
  @file_path = file_path
99
89
  @file_name = file_name
90
+ @debug = debug
100
91
  @line_number = line_number
101
-
102
- input = Normalizer.normalize_spec!(input)
103
-
104
- # Don't pass this down to the expectations
105
- @debug = input.delete(:debug) || false
106
-
107
- global_options = normalize_global_options(input)
108
-
109
- @expectations =
110
- input[:expectations].map.with_index do |expectation_input, index|
111
- Expectation.new(expectation_input, global_options:)
112
- end
113
- end
114
-
115
- def define
116
- Runner.define_spec(self)
92
+ @expectations = expectations.map { |e| Expectation.new(**e) }
117
93
  end
118
94
 
119
- private
120
-
121
- def normalize_global_options(input)
122
- config = SpecForge.configuration.to_h.slice(:base_url, :headers, :query)
123
- Configuration.overlay_options(config, input.except(:expectations))
95
+ #
96
+ # Converts the spec to a hash representation
97
+ #
98
+ # @return [Hash] Hash representation
99
+ #
100
+ def to_h
101
+ {
102
+ name:,
103
+ file_path:,
104
+ file_name:,
105
+ debug:,
106
+ line_number:,
107
+ expectations: expectations.map(&:to_h)
108
+ }
124
109
  end
125
110
  end
126
111
  end
112
+
113
+ require_relative "spec/expectation"
@@ -1,24 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
+ #
5
+ # Provides helper methods for checking types
6
+ # Useful for working with both regular objects and Attribute delegators
7
+ #
4
8
  module Type
5
9
  #
6
- # Checks if the object is a Hash, or a ResolvableHash (delegator)
10
+ # Checks if the object is a Hash or a ResolvableHash delegator
7
11
  #
8
12
  # @param object [Object] The object to check
9
13
  #
10
- # @return [Boolean]
14
+ # @return [Boolean] True if the object is a hash-like structure
11
15
  #
12
16
  def self.hash?(object)
13
17
  object.is_a?(Hash) || object.is_a?(Attribute::ResolvableHash)
14
18
  end
15
19
 
16
20
  #
17
- # Checks if the object is an Array, or a ResolvableArray (delegator)
21
+ # Checks if the object is an Array or a ResolvableArray delegator
18
22
  #
19
23
  # @param object [Object] The object to check
20
24
  #
21
- # @return [Boolean]
25
+ # @return [Boolean] True if the object is an array-like structure
22
26
  #
23
27
  def self.array?(object)
24
28
  object.is_a?(Array) || object.is_a?(Attribute::ResolvableArray)
@@ -28,8 +32,22 @@ end
28
32
 
29
33
  #
30
34
  # Represents Hash/ResolvableHash in a form that can be used in a case statement
35
+ # Allows for type switching on hash-like objects
36
+ #
37
+ # @example
38
+ # case value
39
+ # when HashLike
40
+ # # Handle hash-like objects
41
+ # end
31
42
  #
32
43
  class HashLike
44
+ #
45
+ # Provides custom type matching for use in case statements
46
+ #
47
+ # @param object [Object] The object to check against the type
48
+ #
49
+ # @return [Boolean] Whether the object matches the type
50
+ #
33
51
  def self.===(object)
34
52
  SpecForge::Type.hash?(object)
35
53
  end
@@ -37,8 +55,22 @@ end
37
55
 
38
56
  #
39
57
  # Represents Array/ResolvableArray in a form that can be used in a case statement
58
+ # Allows for type switching on array-like objects
59
+ #
60
+ # @example
61
+ # case value
62
+ # when ArrayLike
63
+ # # Handle array-like objects
64
+ # end
40
65
  #
41
66
  class ArrayLike
67
+ #
68
+ # Provides custom type matching for use in case statements
69
+ #
70
+ # @param object [Object] The object to check against the type
71
+ #
72
+ # @return [Boolean] Whether the object matches the type
73
+ #
42
74
  def self.===(object)
43
75
  SpecForge::Type.array?(object)
44
76
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
- VERSION = "0.4.0"
4
+ #
5
+ # Current version of SpecForge
6
+ #
7
+ VERSION = "0.6.0"
5
8
  end