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
@@ -2,6 +2,15 @@
2
2
 
3
3
  module SpecForge
4
4
  class CLI
5
+ #
6
+ # Command for generating new specs or factories
7
+ #
8
+ # @example Creating a new spec
9
+ # spec_forge new spec users
10
+ #
11
+ # @example Creating a new factory
12
+ # spec_forge new factory user
13
+ #
5
14
  class New < Command
6
15
  command_name "new"
7
16
  summary "Create a new spec or factory"
@@ -19,6 +28,9 @@ module SpecForge
19
28
 
20
29
  aliases :generate, :g
21
30
 
31
+ #
32
+ # Creates a new spec or factory file in the corresponding directory using templates
33
+ #
22
34
  def call
23
35
  type = arguments.first.downcase
24
36
  name = arguments.second
@@ -40,7 +52,7 @@ module SpecForge
40
52
  def create_new_spec(name)
41
53
  actions.template(
42
54
  "new_spec.tt",
43
- SpecForge.forge.join("specs", "#{name}.yml"),
55
+ SpecForge.forge_path.join("specs", "#{name}.yml"),
44
56
  context: Proxy.new(name).call
45
57
  )
46
58
  end
@@ -48,20 +60,59 @@ module SpecForge
48
60
  def create_new_factory(name)
49
61
  actions.template(
50
62
  "new_factory.tt",
51
- SpecForge.forge.join("factories", "#{name}.yml"),
63
+ SpecForge.forge_path.join("factories", "#{name}.yml"),
52
64
  context: Proxy.new(name).call
53
65
  )
54
66
  end
55
67
 
68
+ #
69
+ # Helper class for passing template variables to Thor templates
70
+ #
71
+ # @example Creating a proxy with a name
72
+ # proxy = Proxy.new("user")
73
+ # proxy.singular_name # => "user"
74
+ # proxy.plural_name # => "users"
75
+ #
56
76
  class Proxy
57
- attr_reader :original_name, :singular_name, :plural_name
77
+ #
78
+ # The original name passed to the command
79
+ #
80
+ # @return [String]
81
+ #
82
+ attr_reader :original_name
58
83
 
84
+ #
85
+ # The singular form of the name
86
+ #
87
+ # @return [String]
88
+ #
89
+ attr_reader :singular_name
90
+
91
+ #
92
+ # The plural form of the name
93
+ #
94
+ # @return [String]
95
+ #
96
+ attr_reader :plural_name
97
+
98
+ #
99
+ # Creates a new Proxy with the specified name
100
+ #
101
+ # @param name [String] The resource name to pluralize/singularize
102
+ #
103
+ # @return [Proxy] A new proxy instance
104
+ #
59
105
  def initialize(name)
60
106
  @original_name = name
61
107
  @plural_name = name.pluralize
62
108
  @singular_name = name.singularize
63
109
  end
64
110
 
111
+ #
112
+ # Returns a binding for use in templates
113
+ #
114
+ # @return [Binding] A binding containing template variables
115
+ #
65
116
  def call
66
117
  binding
67
118
  end
@@ -2,6 +2,21 @@
2
2
 
3
3
  module SpecForge
4
4
  class CLI
5
+ #
6
+ # Command for running SpecForge tests with filtering options
7
+ #
8
+ # @example Running all specs
9
+ # spec_forge run
10
+ #
11
+ # @example Running specific file
12
+ # spec_forge run users
13
+ #
14
+ # @example Running specific spec
15
+ # spec_forge run users:create_user
16
+ #
17
+ # @example Running specific expectation
18
+ # spec_forge run users:create_user:"POST /users"
19
+ #
5
20
  class Run < Command
6
21
  command_name "run"
7
22
  syntax "run [target]"
@@ -26,6 +41,9 @@ module SpecForge
26
41
 
27
42
  # option "-n", "--no-docs", "Do not generate OpenAPI documentation on completion"
28
43
 
44
+ #
45
+ # Loads and runs all specs, or a subset of specs based on the provided arguments
46
+ #
29
47
  def call
30
48
  return SpecForge.run if arguments.blank?
31
49
 
@@ -53,6 +71,8 @@ module SpecForge
53
71
  # Example with name:
54
72
  # "users:show_user:'GET /users/:id - Returns 404 due to missing user'"
55
73
  #
74
+ # @private
75
+ #
56
76
  def extract_filter(input)
57
77
  # Note: Only split 3 because the expectation name can have colons in them.
58
78
  file_name, spec_name, expectation_name = input.split(":", 3).map(&:strip)
@@ -7,15 +7,26 @@ require_relative "cli/new"
7
7
  require_relative "cli/run"
8
8
 
9
9
  module SpecForge
10
+ #
11
+ # Command-line interface for SpecForge that provides the overall command structure
12
+ # and entry point for the CLI functionality.
13
+ #
14
+ # @example Running the default command
15
+ # SpecForge::CLI.new.run
16
+ #
17
+ # @example Running a specific command
18
+ # # From command line: spec_forge init
19
+ #
10
20
  class CLI
11
21
  include Commander::Methods
12
22
 
13
- COMMANDS = [Init, New, Run]
14
-
15
23
  #
16
- # Runs the CLI
24
+ # @return [Array<SpecForge::CLI::Command>] All available commands
17
25
  #
18
- # @private
26
+ COMMANDS = [Init, New, Run].freeze
27
+
28
+ #
29
+ # Runs the CLI application, setting up program information and registering commands
19
30
  #
20
31
  def run
21
32
  program :name, "SpecForge"
@@ -30,7 +41,7 @@ module SpecForge
30
41
  end
31
42
 
32
43
  #
33
- # Registers the commands with Commander
44
+ # Registers the command classes with Commander
34
45
  #
35
46
  # @private
36
47
  #
@@ -1,43 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
- class Configuration < Struct.new(:base_url, :headers, :query, :factories, :specs, :on_debug)
5
- ############################################################################
6
-
4
+ #
5
+ # Configuration container for SpecForge settings
6
+ # Defines default values and validation for all configuration options
7
+ #
8
+ class Configuration < Struct.new(:base_url, :headers, :query, :factories, :on_debug)
9
+ #
10
+ # Manages factory configuration settings
11
+ # Controls auto-discovery behavior and custom factory paths
12
+ #
13
+ # @example
14
+ # config.factories.auto_discover = false
15
+ # config.factories.paths += ["lib/factories"]
16
+ #
7
17
  class Factories < Struct.new(:auto_discover, :paths)
18
+ #
19
+ # Creates reader methods that return boolean values
20
+ # Allows for checking configuration with predicate methods
21
+ #
8
22
  attr_predicate :auto_discover, :paths
9
23
 
24
+ #
25
+ # Initializes a new Factories configuration
26
+ # Sets default values for auto-discovery and paths
27
+ #
28
+ # @param auto_discover [Boolean] Whether to auto-discover factories (default: true)
29
+ # @param paths [Array<String>] Additional paths to look for factories (default: [])
30
+ #
31
+ # @return [Factories] A new factories configuration instance
32
+ #
10
33
  def initialize(auto_discover: true, paths: []) = super
11
34
  end
12
35
 
13
- ############################################################################
14
-
15
- def self.overlay_options(source, overlay)
16
- source.deep_merge(overlay) do |key, source_value, overlay_value|
17
- # If overlay has a populated value, use it
18
- if overlay_value.present? || overlay_value == false
19
- overlay_value
20
- # If source is nil and overlay exists (but wasn't "present"), use overlay
21
- elsif source_value.nil? && !overlay_value.nil?
22
- overlay_value
23
- # Otherwise keep source value
24
- else
25
- source_value
26
- end
27
- end
28
- end
29
-
36
+ #
37
+ # Initializes a new Configuration with default values
38
+ # Sets up the configuration structure including factory settings and debug proxy
39
+ #
40
+ # @return [Configuration] A new configuration instance with defaults
41
+ #
30
42
  def initialize
31
43
  config = Normalizer.default_configuration
32
44
 
33
45
  config[:base_url] = "http://localhost:3000"
34
46
  config[:factories] = Factories.new
35
- config[:specs] = RSpec.configuration
36
47
  config[:on_debug] = Runner::DebugProxy.default
37
48
 
38
49
  super(**config)
39
50
  end
40
51
 
52
+ #
53
+ # Validates the configuration and applies normalization
54
+ # Ensures all required fields have values and applies defaults when needed
55
+ #
56
+ # @return [self] Returns self for method chaining
57
+ #
58
+ # @api private
59
+ #
41
60
  def validate
42
61
  output = Normalizer.normalize_configuration!(to_h)
43
62
 
@@ -49,10 +68,63 @@ module SpecForge
49
68
  self
50
69
  end
51
70
 
71
+ #
72
+ # Recursively converts the configuration to a hash representation
73
+ #
74
+ # @return [Hash] Hash representation of the configuration
75
+ #
52
76
  def to_h
53
- hash = super.except(:specs)
77
+ hash = super
54
78
  hash[:factories] = hash[:factories].to_h
55
79
  hash
56
80
  end
81
+
82
+ #
83
+ # Returns the RSpec configuration object
84
+ # Provides access to RSpec's internal configuration for test customization
85
+ #
86
+ # @return [RSpec::Core::Configuration] RSpec's configuration object
87
+ #
88
+ # @example Setting formatter options
89
+ # SpecForge.configure do |config|
90
+ # config.specs.formatter = :documentation
91
+ # end
92
+ #
93
+ def specs
94
+ RSpec.configuration
95
+ end
96
+
97
+ alias_method :rspec, :specs
98
+
99
+ #
100
+ # Registers a callback for a specific test lifecycle event
101
+ # Allows custom code execution at specific points during test execution
102
+ #
103
+ # @param name [Symbol, String] The callback point to register for
104
+ # (:before_file, :after_expectation, etc.)
105
+ # @yield A block to execute when the callback is triggered
106
+ # @yieldparam context [Object] An object containing context-specific state data, depending
107
+ # on which hook the callback is triggered from.
108
+ #
109
+ # @return [Proc] The registered callback
110
+ #
111
+ # @example Registering a custom debug handler
112
+ # SpecForge.configure do |config|
113
+ # config.register_callback(:on_debug) { binding.pry }
114
+ # end
115
+ #
116
+ # @example Cleaning database after each test
117
+ # SpecForge.configure do |config|
118
+ # config.register_callback(:after_expectation) do
119
+ # DatabaseCleaner.clean
120
+ # end
121
+ # end
122
+ #
123
+ def register_callback(name, &)
124
+ Callbacks.register(name, &)
125
+ end
126
+
127
+ alias_method :define_callback, :register_callback
128
+ alias_method :callback, :register_callback
57
129
  end
58
130
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages user-defined callbacks grouped by lifecycle hook
7
+ #
8
+ # This class collects and organizes callbacks by their hook type
9
+ # (before_file, after_each, etc.) to support the test lifecycle.
10
+ # It ensures callbacks are properly categorized for execution.
11
+ #
12
+ # @example Creating callback groups
13
+ # callbacks = Context::Callbacks.new([
14
+ # {before_file: "setup_environment"},
15
+ # {after_each: "log_test_result"}
16
+ # ])
17
+ #
18
+ class Callbacks
19
+ #
20
+ # Creates a new callbacks collection
21
+ #
22
+ # @param callback_array [Array] Optional initial callbacks to register
23
+ #
24
+ # @return [Callbacks] A new callbacks collection
25
+ #
26
+ def initialize(callback_array = [])
27
+ set(callback_array)
28
+ end
29
+
30
+ #
31
+ # Updates the callbacks collection
32
+ #
33
+ # @param callback_array [Array] New callbacks to register
34
+ #
35
+ # @return [self]
36
+ #
37
+ def set(callback_array)
38
+ @inner = organize_callbacks_by_hook(callback_array)
39
+ self
40
+ end
41
+
42
+ #
43
+ # Returns the hash representation of callbacks
44
+ #
45
+ # @return [Hash] Callbacks organized by hook type
46
+ #
47
+ def to_h
48
+ @inner
49
+ end
50
+
51
+ #
52
+ # Executes all registered callbacks for a specific lifecycle hook
53
+ #
54
+ # @param hook_name [String, Symbol] The lifecycle hook (before_file, after_each, etc.)
55
+ # @param context [Hash] State data that will be converted to a structured object
56
+ # and passed to callbacks
57
+ #
58
+ def run(hook_name, context = {})
59
+ context = context.to_struct
60
+
61
+ @inner[hook_name].each do |callback_name|
62
+ SpecForge::Callbacks.run(callback_name, context)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ #
69
+ # Organizes callbacks from an array to hash structure by hook type
70
+ # Groups callbacks like before_file, after_each, etc. for easier lookup
71
+ #
72
+ # @param callback_array [Array] The array of callbacks
73
+ #
74
+ # @return [Hash] Callbacks indexed by hook type
75
+ #
76
+ # @private
77
+ #
78
+ def organize_callbacks_by_hook(callback_array)
79
+ groups = Hash.new { |h, k| h[k] = Set.new }
80
+
81
+ callback_array.each_with_object(groups) do |callbacks, groups|
82
+ callbacks.each do |hook, name|
83
+ next if name.blank?
84
+
85
+ groups[hook].add(name)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages global state and variables at the spec file level.
7
+ #
8
+ # The Global class provides access to variables that are defined at the global level
9
+ # in a spec file and are accessible across all specs and expectations in a file.
10
+ # Unlike regular variables, global variables do not support overlaying - they maintain
11
+ # consistent values throughout test execution.
12
+ #
13
+ # @example Basic usage
14
+ # global = Global.new(variables: {api_version: "v2", environment: "test"})
15
+ #
16
+ # global.variables[:api_version] #=> "v2"
17
+ # global.variables[:environment] #=> "test"
18
+ #
19
+ # # Update global variables
20
+ # global.set(variables: {environment: "staging"})
21
+ # global.variables[:environment] #=> "staging"
22
+ # global.variables[:api_version] #=> nil
23
+ #
24
+ class Global
25
+ # @return [Context::Variables] The container for global variables
26
+ attr_reader :variables
27
+
28
+ # @return [Context::Callbacks] The container for callbacks
29
+ attr_reader :callbacks
30
+
31
+ #
32
+ # Creates a new Global context instance
33
+ #
34
+ # @param variables [Hash<Symbol, Object>] A hash of variable names and values
35
+ # @param callbacks [Array<Hash<Symbol, String>>] An array of callback hooks
36
+ #
37
+ # @return [Global] The new Global instance
38
+ #
39
+ def initialize(variables: {}, callbacks: [])
40
+ @variables = Variables.new(base: variables)
41
+ @callbacks = Callbacks.new(callbacks)
42
+ end
43
+
44
+ #
45
+ # Sets the global variables
46
+ #
47
+ # @param variables [Hash<Symbol, Object>] A hash of variable names and values
48
+ # @param callbacks [Array<Hash<Symbol, String>>] An array of callback hooks
49
+ #
50
+ # @return [self]
51
+ #
52
+ def set(variables: {}, callbacks: [])
53
+ @variables.set(base: variables)
54
+ @callbacks.set(callbacks)
55
+
56
+ self
57
+ end
58
+
59
+ #
60
+ # Returns a hash representation of the global context
61
+ #
62
+ # @return [Hash]
63
+ #
64
+ def to_h
65
+ {
66
+ variables: variables.to_h,
67
+ callbacks: callbacks.to_h
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Context
5
+ #
6
+ # Manages storage of API responses for use in subsequent tests
7
+ #
8
+ # This class provides a mechanism to store HTTP requests and responses
9
+ # during test execution, allowing values to be referenced in later tests
10
+ # through the `store.id.body.attribute` syntax.
11
+ #
12
+ # @example Storing and retrieving a response in specs
13
+ # # In one expectation:
14
+ # store_as: user_creation
15
+ #
16
+ # # In a later test:
17
+ # query:
18
+ # id: store.user_creation.body.id
19
+ #
20
+ class Store
21
+ #
22
+ # Represents a single stored entry with request, variables, and response data
23
+ #
24
+ # Entries are immutable once created and contain a deep-frozen
25
+ # snapshot of the test state at the time of storage.
26
+ #
27
+ # @example Accessing stored entry data
28
+ # entry = store["user_creation"]
29
+ # entry.status # => 201
30
+ # entry.body.id # => 42
31
+ #
32
+ class Entry < Data.define(:scope, :request, :variables, :response)
33
+ #
34
+ # Creates a new immutable store entry
35
+ #
36
+ # @param request [Hash] The HTTP request that was executed
37
+ # @param variables [Hash] Variables from the test context
38
+ # @param response [Hash] The HTTP response received
39
+ # @param scope [Symbol] Scope of this entry, either :file or :spec
40
+ #
41
+ # @return [Entry] A new immutable entry instance
42
+ #
43
+ def initialize(request:, variables:, response:, scope: :file)
44
+ request = request.deep_freeze
45
+ variables = variables.deep_freeze
46
+ response = response.deep_freeze
47
+
48
+ super
49
+ end
50
+
51
+ #
52
+ # Shorthand accessor for the HTTP status code
53
+ #
54
+ # @return [Integer] The response status code
55
+ #
56
+ def status = response[:status]
57
+
58
+ #
59
+ # Shorthand accessor for the response body
60
+ #
61
+ # @return [Hash, Array, String] The parsed response body
62
+ #
63
+ def body = response[:body]
64
+
65
+ #
66
+ # Shorthand accessor for the response headers
67
+ #
68
+ # @return [Hash] The response headers
69
+ #
70
+ def headers = response[:headers]
71
+
72
+ #
73
+ # Returns all available methods that can be called
74
+ #
75
+ # @return [Array] The method names
76
+ #
77
+ def available_methods
78
+ members + [:status, :body, :headers]
79
+ end
80
+ end
81
+
82
+ #
83
+ # Creates a new empty store
84
+ #
85
+ # @return [Store] A new store instance
86
+ #
87
+ def initialize
88
+ @inner = {}
89
+ end
90
+
91
+ #
92
+ # Retrieves a stored entry by ID
93
+ #
94
+ # @param id [String, Symbol] The identifier for the stored entry
95
+ #
96
+ # @return [Entry, nil] The stored entry or nil if not found
97
+ #
98
+ def [](id)
99
+ @inner[id]
100
+ end
101
+
102
+ #
103
+ # Returns the number of entries in the store
104
+ #
105
+ # @return [Integer] The count of stored entries
106
+ #
107
+ def size
108
+ @inner.size
109
+ end
110
+
111
+ #
112
+ # Stores an entry with the specified ID
113
+ #
114
+ # @param id [String, Symbol] The identifier to store the entry under
115
+ #
116
+ # @return [self]
117
+ #
118
+ def set(id, **)
119
+ @inner[id] = Entry.new(**)
120
+
121
+ self
122
+ end
123
+
124
+ #
125
+ # Removes all entries from the store
126
+ #
127
+ def clear
128
+ @inner.clear
129
+ end
130
+
131
+ #
132
+ # Removes all spec entries from the store
133
+ #
134
+ def clear_specs
135
+ @inner.delete_if { |_k, v| v.scope == :spec }
136
+ end
137
+
138
+ #
139
+ # Returns a hash representation of store
140
+ #
141
+ # @return [Hash]
142
+ #
143
+ def to_h
144
+ @inner.transform_values(&:to_h).deep_stringify_keys
145
+ end
146
+ end
147
+ end
148
+ end