spec_forge 0.7.0 → 1.0.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -9
  3. data/README.md +125 -203
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +6 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +212 -78
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +22 -6
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +29 -7
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -143
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -74
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -183
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -213
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -3,23 +3,19 @@
3
3
  module SpecForge
4
4
  module HTTP
5
5
  #
6
- # HTTP client that executes requests and returns responses
6
+ # High-level HTTP client for executing requests
7
7
  #
8
- # This class serves as a mediator between the test expectations
9
- # and the actual HTTP backend implementation.
10
- #
11
- # @example Basic usage
12
- # client = HTTP::Client.new(base_url: "https://api.example.com")
13
- # response = client.call(request)
8
+ # Client wraps Backend and provides a simple interface for executing
9
+ # HTTP::Request objects and returning responses.
14
10
  #
15
11
  class Client
16
12
  #
17
- # Creates a new HTTP client with configured backend
13
+ # Creates a new HTTP client with a backend
18
14
  #
19
- # @return [Client] A new HTTP client instance
15
+ # @return [Client] A new client instance
20
16
  #
21
- def initialize(**)
22
- @backend = Backend.new(HTTP::Request.new(**))
17
+ def initialize
18
+ @backend = Backend.new
23
19
  end
24
20
 
25
21
  #
@@ -29,13 +25,14 @@ module SpecForge
29
25
  #
30
26
  # @return [Faraday::Response] The HTTP response
31
27
  #
32
- def call(request)
28
+ def perform(request)
33
29
  @backend.public_send(
34
- request.http_verb.to_s.downcase,
35
- request.url,
36
- headers: request.headers.resolved,
37
- query: request.query.resolved,
38
- body: request.body.resolved
30
+ request.http_verb.downcase,
31
+ base_url: request.base_url,
32
+ url: request.url.delete_prefix("/"),
33
+ headers: request.headers,
34
+ query: request.query,
35
+ body: request.body
39
36
  )
40
37
  end
41
38
  end
@@ -3,110 +3,63 @@
3
3
  module SpecForge
4
4
  module HTTP
5
5
  #
6
- # The attributes used to build a Request
6
+ # Represents an HTTP request with all its components
7
7
  #
8
- # @return [Array<Symbol>]
8
+ # Request is a value object that holds the URL, method, headers,
9
+ # query parameters, and body for an HTTP request.
9
10
  #
10
- REQUEST_ATTRIBUTES = %i[
11
- base_url
12
- url
13
- http_verb
14
- content_type
15
- headers
16
- query
17
- body
18
- ].freeze
19
-
20
- #
21
- # Represents an HTTP request configuration
22
- #
23
- # This data object contains all the necessary information to construct
24
- # an HTTP request, including URL, method, headers, query params, and body.
25
- #
26
- # @example Creating a request
27
- # request = HTTP::Request.new(
28
- # base_url: "https://api.example.com",
29
- # url: "/users",
30
- # http_verb: "GET",
31
- # headers: {"Content-Type" => "application/json"},
32
- # query: {page: 1},
33
- # body: {}
34
- # )
35
- #
36
- class Request < Data.define(*REQUEST_ATTRIBUTES)
37
- #
38
- # Regex that attempts to match a valid header
39
- #
40
- # @return [Regexp]
41
- #
42
- HEADER = /^[A-Z][A-Za-z0-9!-]*$/
43
-
11
+ class Request < Struct.new(:base_url, :url, :http_verb, :headers, :query, :body)
44
12
  #
45
- # Creates a new Request with standardized headers and values
13
+ # Creates a new HTTP request with the specified options
46
14
  #
47
- # @param base_url [String, nil] The base URL for the request
48
- # @param url [String, nil] The path portion of the URL
49
- # @param http_verb [String, Symbol, nil] The HTTP method (GET, POST, etc.)
50
- # @param headers [Hash, nil] HTTP headers for the request
51
- # @param query [Hash, nil] Query parameters to include
52
- # @param body [Hash, nil] Request body data
15
+ # @param options [Hash] Request options
16
+ # @option options [String] :base_url The base URL for the request
17
+ # @option options [String] :url The URL path for the request
18
+ # @option options [String] :http_verb The HTTP method (defaults to "GET")
19
+ # @option options [Hash] :headers HTTP headers
20
+ # @option options [Hash] :query Query parameters
21
+ # @option options [Hash, String] :body Request body
53
22
  #
54
- # @return [Request] A new immutable request object
23
+ # @return [Request] A new request instance
55
24
  #
56
25
  def initialize(**options)
57
- base_url = options[:base_url] || ""
58
- url = options[:url] || ""
59
- http_verb = Verb.from(options[:http_verb].presence || "GET")
60
- query = Attribute.from(options[:query] || {})
61
- body = Attribute.from(options[:body] || {})
62
- headers = normalize_headers(options[:headers] || {})
63
- content_type = "application/json"
64
-
65
- super(base_url:, url:, http_verb:, content_type:, headers:, query:, body:)
26
+ super(
27
+ base_url: options[:base_url] || "",
28
+ url: options[:url] || "",
29
+ http_verb: Verb.from(options[:http_verb].presence || "GET"),
30
+ headers: options[:headers] || {},
31
+ query: options[:query] || {},
32
+ body: options[:body] || {}
33
+ )
66
34
  end
67
35
 
68
36
  #
69
- # Returns a hash representation with all attributes fully resolved
37
+ # Returns the Content-Type header value
70
38
  #
71
- # @return [Hash] The request data with all dynamic values resolved
39
+ # @return [String, nil] The content type or nil if not set
72
40
  #
73
- def to_h
74
- hash = super.transform_values { |v| v.respond_to?(:resolved) ? v.resolved : v }
75
- hash[:http_verb] = hash[:http_verb].to_s
76
- hash
41
+ def content_type
42
+ headers["content-type"]
77
43
  end
78
44
 
79
- private
80
-
81
45
  #
82
- # Normalizes HTTP header keys to standard format
46
+ # Returns whether this request has a JSON content type
83
47
  #
84
- # Converts snake_case and other formats to HTTP Header-Case format
85
- # Examples:
86
- # content_type -> Content-Type
87
- # api_key -> Api-Key
48
+ # @return [Boolean] True if content type is application/json
88
49
  #
89
- # @param headers [Hash] The headers to normalize
50
+ def json?
51
+ content_type == "application/json"
52
+ end
53
+
90
54
  #
91
- # @return [Attribute::ResolvableHash] Normalized headers as attributes
55
+ # Converts the request to a hash with stringified verb
92
56
  #
93
- # @private
57
+ # @return [Hash] Hash representation of the request
94
58
  #
95
- def normalize_headers(headers)
96
- headers =
97
- headers.transform_keys do |key|
98
- key = key.to_s
99
-
100
- # If the key is already like a header, don't change it
101
- if key.match?(HEADER)
102
- key
103
- else
104
- # content_type => Content-Type
105
- key.downcase.titleize.gsub(/\s+/, "-")
106
- end
107
- end
108
-
109
- Attribute.from(headers)
59
+ def to_h
60
+ super.tap do |h|
61
+ h[:http_verb] = h[:http_verb].to_s
62
+ end
110
63
  end
111
64
  end
112
65
  end
@@ -124,6 +124,8 @@ module SpecForge
124
124
  # @return [Verb, nil] The corresponding Verb instance, or nil if not found
125
125
  #
126
126
  def self.from(name)
127
+ return name if name.is_a?(Verb)
128
+
127
129
  VERBS[name.downcase.to_sym]
128
130
  end
129
131
 
@@ -148,6 +150,8 @@ module SpecForge
148
150
 
149
151
  alias_method :to_s, :name
150
152
 
153
+ delegate :downcase, to: :to_s
154
+
151
155
  #
152
156
  # Returns if this Verb is a DELETE
153
157
  #
@@ -104,8 +104,3 @@ module SpecForge
104
104
  end
105
105
  end
106
106
  end
107
-
108
- require_relative "http/backend"
109
- require_relative "http/client"
110
- require_relative "http/verb"
111
- require_relative "http/request"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Loader
5
+ #
6
+ # Filters blueprints and steps by path and tags
7
+ #
8
+ # Applied after step processing to reduce the set of blueprints
9
+ # and steps that will be executed based on CLI options.
10
+ #
11
+ class Filter
12
+ #
13
+ # Creates a new filter for the given blueprints
14
+ #
15
+ # @param blueprints [Array<Hash>] The blueprints to filter
16
+ #
17
+ # @return [Filter] A new filter instance
18
+ #
19
+ def initialize(blueprints)
20
+ @blueprints = blueprints
21
+ end
22
+
23
+ #
24
+ # Applies filters to the blueprints
25
+ #
26
+ # @param path [Pathname, nil] Path prefix to filter by
27
+ # @param tags [Array<String>] Tags that steps must have (OR logic)
28
+ # @param skip_tags [Array<String>] Tags that exclude steps
29
+ #
30
+ # @return [Array<Hash>] Filtered blueprints
31
+ #
32
+ def run(path: nil, tags: [], skip_tags: [])
33
+ filter_by_path(path)
34
+ filter_by_tags(tags, skip_tags)
35
+ remove_empty_blueprints
36
+
37
+ @blueprints
38
+ end
39
+
40
+ private
41
+
42
+ def filter_by_path(path)
43
+ return if path.blank?
44
+
45
+ base_path = SpecForge.blueprints_path
46
+ path = path.relative_path_from(base_path).to_s
47
+ .delete_suffix(".yml")
48
+ .delete_suffix(".yaml")
49
+
50
+ @blueprints.select! do |blueprint|
51
+ blueprint[:name].starts_with?(path)
52
+ end
53
+ end
54
+
55
+ def filter_by_tags(tags, skip_tags)
56
+ include_by_tag(tags)
57
+ exclude_by_tag(skip_tags)
58
+ end
59
+
60
+ def include_by_tag(tags)
61
+ return if tags.blank?
62
+
63
+ @blueprints.each do |blueprint|
64
+ blueprint[:steps].select! do |step|
65
+ step[:tags].intersect?(tags)
66
+ end
67
+ end
68
+ end
69
+
70
+ def exclude_by_tag(tags)
71
+ return if tags.blank?
72
+
73
+ @blueprints.each do |blueprint|
74
+ blueprint[:steps].reject! do |step|
75
+ step[:tags].intersect?(tags)
76
+ end
77
+ end
78
+ end
79
+
80
+ def remove_empty_blueprints
81
+ @blueprints.delete_if { |blueprint| blueprint[:steps].blank? }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Loader
5
+ #
6
+ # Processes raw step hashes into normalized, flattened step arrays
7
+ #
8
+ # Handles the heavy lifting of load-time processing: normalizing step
9
+ # structures, expanding includes, inheriting configuration from parents,
10
+ # applying tags, and flattening nested hierarchies.
11
+ #
12
+ class StepProcessor
13
+ #
14
+ # Action attributes that execute behavior and cannot be combined with steps
15
+ #
16
+ ACTION_ATTRIBUTES = %i[request expects calls debug store].freeze
17
+
18
+ #
19
+ # Creates a new step processor for the given blueprints
20
+ #
21
+ # @param blueprints [Hash<String, Hash>] Blueprints indexed by name
22
+ #
23
+ # @return [StepProcessor] A new step processor instance
24
+ #
25
+ def initialize(blueprints)
26
+ @blueprints = blueprints
27
+
28
+ @global_hooks = SpecForge.configuration.hooks.transform_values do |callbacks|
29
+ Normalizer::Transformers.call(:normalize_callback, callbacks)
30
+ end
31
+
32
+ @forge_hooks = {
33
+ before: Set.new(@global_hooks[:before_forge]),
34
+ after: Set.new(@global_hooks[:after_forge])
35
+ }
36
+ end
37
+
38
+ #
39
+ # Processes all blueprints and returns the flattened, normalized steps
40
+ #
41
+ # Performs two passes over the blueprints:
42
+ # 1. Preprocessing: normalizes steps and assigns source information
43
+ # 2. Main processing: expands includes, inherits shared config, extracts
44
+ # hooks, applies tags, and flattens nested step hierarchies
45
+ #
46
+ # @return [Array<Array<Hash>, Hash>] Tuple of processed blueprints and forge hooks
47
+ #
48
+ def run
49
+ # Do a preprocessing pass to ensure all steps are normalized and ready to be referenced
50
+ @blueprints.each do |name, blueprint|
51
+ blueprint[:hooks] = {
52
+ before: Set.new(@global_hooks[:before_blueprint]),
53
+ after: Set.new(@global_hooks[:after_blueprint])
54
+ }
55
+
56
+ blueprint[:steps] = assign_source(blueprint[:steps], file_name: blueprint[:name])
57
+ .then { |s| normalize_steps(s) }
58
+ end
59
+
60
+ @blueprints.each do |name, blueprint|
61
+ hooks = {
62
+ before: Set.new(@global_hooks[:before_step]),
63
+ after: Set.new(@global_hooks[:after_step])
64
+ }
65
+
66
+ blueprint[:steps] = expand_steps(blueprint[:steps])
67
+ .then { |s| inherit_shared(s) }
68
+ .then { |s| validate_steps(s) }
69
+ .then { |s| extract_and_assign_hooks(s, blueprint, all: hooks) }
70
+ .then { |s| tag_steps(s) }
71
+ .then { |s| flatten_steps(s) }
72
+ .then { |s| remove_empty_steps(s) }
73
+ end
74
+
75
+ [@blueprints.values, @forge_hooks]
76
+ end
77
+
78
+ private
79
+
80
+ def assign_source(steps, file_name:)
81
+ steps.each do |step|
82
+ step[:source] = {file_name:, line_number: step.delete(:line_number)}
83
+ step[:steps] = assign_source(step[:steps], file_name:) if step[:steps]
84
+ end
85
+ end
86
+
87
+ def normalize_steps(steps)
88
+ return steps if steps.blank?
89
+
90
+ steps.map do |step|
91
+ # System data (not included in normalizer)
92
+ source = step.delete(:source)
93
+
94
+ # We'll normalize these separately (not included in normalizer)
95
+ sub_steps = step.delete(:steps) || []
96
+
97
+ begin
98
+ step = Normalizer.normalize!(step, using: :step, label: "")
99
+ step[:steps] = normalize_steps(sub_steps)
100
+ ensure
101
+ step[:source] = source
102
+ end
103
+
104
+ # Pluralize these to be consistent
105
+ step.rename_key_unordered!(:call, :calls)
106
+ step.rename_key_unordered!(:expect, :expects)
107
+
108
+ step
109
+ rescue => e
110
+ raise Error::LoadStepError.new(e, step)
111
+ end
112
+ end
113
+
114
+ def extract_and_assign_hooks(steps, blueprint, all:)
115
+ steps.each do |step|
116
+ hooks = step.delete(:hook) || {}
117
+ hooks.default = []
118
+
119
+ # Forge
120
+ @forge_hooks[:before].merge(hooks[:before_forge])
121
+ @forge_hooks[:after].merge(hooks[:after_forge])
122
+
123
+ # Blueprint
124
+ blueprint[:hooks][:before].merge(hooks[:before_blueprint])
125
+ blueprint[:hooks][:after].merge(hooks[:after_blueprint])
126
+
127
+ # Step
128
+ before = all[:before].merge(hooks[:before_step])
129
+ after = all[:after].merge(hooks[:after_step])
130
+
131
+ if step[:steps].blank?
132
+ step[:hooks] = {before: before.to_a, after: after.to_a.reverse}
133
+ else
134
+ extract_and_assign_hooks(step[:steps], blueprint, all: all.deep_dup)
135
+ end
136
+ end
137
+ end
138
+
139
+ def expand_steps(steps)
140
+ return if steps.blank?
141
+
142
+ output_steps = []
143
+
144
+ steps.each do |step|
145
+ output_steps <<
146
+ if (names = step.delete(:include)) && names.size > 0
147
+ load_included_steps(step, names)
148
+ else
149
+ step
150
+ end
151
+
152
+ step[:steps] = expand_steps(step[:steps])
153
+ end
154
+
155
+ output_steps
156
+ end
157
+
158
+ def load_included_steps(step, names)
159
+ step[:steps] +=
160
+ names.flat_map do |name|
161
+ blueprint = @blueprints[name]
162
+
163
+ if blueprint.nil?
164
+ raise Error, <<~STRING
165
+ Blueprint #{name.in_quotes} not found
166
+ Referenced in: #{step[:source][:file_name]}:#{step[:source][:line_number]}
167
+
168
+ Available blueprints: #{@blueprints.keys.join_map(", ", &:in_quotes)}
169
+ STRING
170
+ end
171
+
172
+ steps = blueprint[:steps].deep_dup
173
+ steps = assign_included_by(step, steps)
174
+ steps
175
+ end
176
+
177
+ step
178
+ end
179
+
180
+ def assign_included_by(source_step, steps)
181
+ steps.each do |step|
182
+ step[:included_by] = source_step[:source]
183
+ step[:steps] = assign_included_by(source_step, step[:steps]) if step[:steps]
184
+ end
185
+ end
186
+
187
+ def inherit_shared(steps, shared: {})
188
+ steps.each do |step|
189
+ # Apply inherited values to this step
190
+ step[:request] = merge_request(shared[:request], step[:request])
191
+ step[:hook] = merge_hooks(shared[:hook], step[:hook])
192
+
193
+ if step[:steps].present?
194
+ step_shared = step.delete(:shared) || {}
195
+
196
+ # Combine parent's shared with this step's shared for children
197
+ inherit_shared(
198
+ step[:steps],
199
+ shared: {
200
+ request: merge_request(shared[:request], step_shared[:request]),
201
+ hook: merge_hooks(shared[:hook], step_shared[:hook])
202
+ }
203
+ )
204
+ end
205
+ end
206
+ end
207
+
208
+ def merge_request(parent, child)
209
+ return child if parent.blank?
210
+ return parent if child.blank?
211
+
212
+ parent.deep_merge(child)
213
+ end
214
+
215
+ def merge_hooks(parent, child)
216
+ return child if parent.blank?
217
+ return parent if child.blank?
218
+
219
+ parent.merge(child) { |_, a, b| a + b }
220
+ end
221
+
222
+ def tag_steps(steps, parent_tags: [])
223
+ steps.each do |step|
224
+ step[:tags] = (parent_tags + (step[:tags] || [])).uniq
225
+
226
+ tag_steps(step[:steps], parent_tags: step[:tags]) if step[:steps]
227
+ end
228
+ end
229
+
230
+ def flatten_steps(steps)
231
+ steps.flat_map do |step|
232
+ if (sub_steps = step.delete(:steps))
233
+ flatten_steps(sub_steps)
234
+ else
235
+ step
236
+ end
237
+ end
238
+ end
239
+
240
+ def remove_empty_steps(steps)
241
+ steps.select do |step|
242
+ step = step.except(:name, :source, :tags, :documentation)
243
+ step.compact_blank!
244
+
245
+ step.except(:hooks).present?
246
+ end
247
+ end
248
+
249
+ def validate_steps(steps)
250
+ steps.each do |step|
251
+ validate_no_action_with_steps(step)
252
+ validate_expects_requires_request(step)
253
+
254
+ validate_steps(step[:steps]) if step[:steps].present?
255
+ end
256
+ end
257
+
258
+ def validate_no_action_with_steps(step)
259
+ return if step[:steps].blank?
260
+
261
+ action_attrs = ACTION_ATTRIBUTES.select { |attr| step[attr].present? }
262
+ return if action_attrs.empty?
263
+
264
+ raise Error::InvalidStepError.new(
265
+ "cannot combine action attributes (#{action_attrs.join(", ")}) with steps. " \
266
+ "Use 'shared:' to pass configuration to substeps, or separate into distinct steps.",
267
+ step
268
+ )
269
+ end
270
+
271
+ def validate_expects_requires_request(step)
272
+ return if step[:expects].blank?
273
+ return if step[:request].present?
274
+
275
+ raise Error::InvalidStepError.new(
276
+ "cannot use \"expects\" without \"request\" - nothing to validate against",
277
+ step
278
+ )
279
+ end
280
+ end
281
+ end
282
+ end