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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -9
- data/README.md +125 -203
- data/bin/spec_forge +1 -1
- data/flake.lock +76 -4
- data/flake.nix +5 -4
- data/lib/spec_forge/attribute/chainable.rb +6 -6
- data/lib/spec_forge/attribute/environment.rb +45 -0
- data/lib/spec_forge/attribute/factory.rb +26 -17
- data/lib/spec_forge/attribute/faker.rb +6 -1
- data/lib/spec_forge/attribute/generate.rb +114 -0
- data/lib/spec_forge/attribute/literal.rb +1 -14
- data/lib/spec_forge/attribute/matcher.rb +6 -2
- data/lib/spec_forge/attribute/parameterized.rb +20 -22
- data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
- data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
- data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
- data/lib/spec_forge/attribute/template.rb +118 -0
- data/lib/spec_forge/attribute/transform.rb +14 -19
- data/lib/spec_forge/attribute/variable.rb +31 -31
- data/lib/spec_forge/attribute.rb +54 -100
- data/lib/spec_forge/blueprint.rb +27 -0
- data/lib/spec_forge/cli/docs/generate.rb +28 -8
- data/lib/spec_forge/cli/docs.rb +5 -2
- data/lib/spec_forge/cli/init.rb +4 -4
- data/lib/spec_forge/cli/new.rb +78 -27
- data/lib/spec_forge/cli/run.rb +84 -52
- data/lib/spec_forge/cli/serve.rb +6 -0
- data/lib/spec_forge/cli.rb +6 -14
- data/lib/spec_forge/configuration.rb +212 -78
- data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
- data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
- data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
- data/lib/spec_forge/documentation/builder.rb +77 -329
- data/lib/spec_forge/documentation/document/operation.rb +4 -4
- data/lib/spec_forge/documentation/document.rb +0 -6
- data/lib/spec_forge/documentation/generator.rb +88 -0
- data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
- data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +22 -6
- data/lib/spec_forge/documentation/openapi/v3_0/response.rb +29 -7
- data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
- data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
- data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
- data/lib/spec_forge/documentation/openapi.rb +40 -12
- data/lib/spec_forge/documentation.rb +1 -7
- data/lib/spec_forge/error.rb +215 -41
- data/lib/spec_forge/factory.rb +38 -18
- data/lib/spec_forge/forge/action.rb +41 -0
- data/lib/spec_forge/forge/actions/call.rb +33 -0
- data/lib/spec_forge/forge/actions/debug.rb +47 -0
- data/lib/spec_forge/forge/actions/expect.rb +44 -0
- data/lib/spec_forge/forge/actions/request.rb +65 -0
- data/lib/spec_forge/forge/actions/store.rb +31 -0
- data/lib/spec_forge/forge/callbacks.rb +80 -0
- data/lib/spec_forge/forge/context.rb +41 -0
- data/lib/spec_forge/forge/display.rb +503 -0
- data/lib/spec_forge/forge/hooks.rb +131 -0
- data/lib/spec_forge/forge/runner/array_io.rb +81 -0
- data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
- data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
- data/lib/spec_forge/forge/runner/reporter.rb +56 -0
- data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
- data/lib/spec_forge/forge/runner.rb +118 -0
- data/lib/spec_forge/forge/timer.rb +94 -0
- data/lib/spec_forge/forge/variables.rb +38 -0
- data/lib/spec_forge/forge.rb +207 -133
- data/lib/spec_forge/http/backend.rb +49 -143
- data/lib/spec_forge/http/client.rb +14 -17
- data/lib/spec_forge/http/request.rb +37 -84
- data/lib/spec_forge/http/verb.rb +4 -0
- data/lib/spec_forge/http.rb +0 -5
- data/lib/spec_forge/loader/filter.rb +85 -0
- data/lib/spec_forge/loader/step_processor.rb +282 -0
- data/lib/spec_forge/loader.rb +105 -220
- data/lib/spec_forge/normalizer/default.rb +1 -1
- data/lib/spec_forge/normalizer/structure.rb +140 -0
- data/lib/spec_forge/normalizer/transformers.rb +168 -0
- data/lib/spec_forge/normalizer/validators.rb +50 -8
- data/lib/spec_forge/normalizer.rb +76 -119
- data/lib/spec_forge/normalizers/callback.yml +38 -0
- data/lib/spec_forge/normalizers/configuration.yml +59 -9
- data/lib/spec_forge/normalizers/factory.yml +53 -2
- data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
- data/lib/spec_forge/normalizers/json_schema.yml +79 -0
- data/lib/spec_forge/normalizers/step.yml +506 -0
- data/lib/spec_forge/step/call.rb +36 -0
- data/lib/spec_forge/step/expect.rb +110 -0
- data/lib/spec_forge/step/source.rb +22 -0
- data/lib/spec_forge/step.rb +129 -0
- data/lib/spec_forge/type.rb +115 -66
- data/lib/spec_forge/version.rb +1 -1
- data/lib/spec_forge.rb +44 -106
- data/lib/templates/forge_helper.rb.tt +43 -22
- data/lib/templates/new_blueprint.yml.tt +54 -0
- metadata +75 -44
- data/lib/spec_forge/attribute/global.rb +0 -96
- data/lib/spec_forge/attribute/store.rb +0 -65
- data/lib/spec_forge/backtrace_formatter.rb +0 -50
- data/lib/spec_forge/callbacks.rb +0 -88
- data/lib/spec_forge/context/callbacks.rb +0 -91
- data/lib/spec_forge/context/global.rb +0 -72
- data/lib/spec_forge/context/store.rb +0 -131
- data/lib/spec_forge/context/variables.rb +0 -91
- data/lib/spec_forge/context.rb +0 -36
- data/lib/spec_forge/core_ext/rspec.rb +0 -55
- data/lib/spec_forge/core_ext.rb +0 -5
- data/lib/spec_forge/documentation/generators/base.rb +0 -81
- data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
- data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
- data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
- data/lib/spec_forge/documentation/generators.rb +0 -17
- data/lib/spec_forge/documentation/loader.rb +0 -159
- data/lib/spec_forge/documentation/openapi/base.rb +0 -33
- data/lib/spec_forge/filter.rb +0 -86
- data/lib/spec_forge/normalizer/definition.rb +0 -248
- data/lib/spec_forge/normalizers/_shared.yml +0 -74
- data/lib/spec_forge/normalizers/constraint.yml +0 -8
- data/lib/spec_forge/normalizers/expectation.yml +0 -47
- data/lib/spec_forge/normalizers/global_context.yml +0 -28
- data/lib/spec_forge/normalizers/spec.yml +0 -50
- data/lib/spec_forge/runner/adapter.rb +0 -183
- data/lib/spec_forge/runner/callbacks.rb +0 -246
- data/lib/spec_forge/runner/debug_proxy.rb +0 -213
- data/lib/spec_forge/runner/listener.rb +0 -54
- data/lib/spec_forge/runner/metadata.rb +0 -58
- data/lib/spec_forge/runner/state.rb +0 -98
- data/lib/spec_forge/runner.rb +0 -75
- data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
- data/lib/spec_forge/spec/expectation.rb +0 -68
- data/lib/spec_forge/spec.rb +0 -68
- 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
|
|
6
|
+
# High-level HTTP client for executing requests
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
13
|
+
# Creates a new HTTP client with a backend
|
|
18
14
|
#
|
|
19
|
-
# @return [Client] A new
|
|
15
|
+
# @return [Client] A new client instance
|
|
20
16
|
#
|
|
21
|
-
def initialize
|
|
22
|
-
@backend = Backend.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
|
|
28
|
+
def perform(request)
|
|
33
29
|
@backend.public_send(
|
|
34
|
-
request.http_verb.
|
|
35
|
-
request.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
6
|
+
# Represents an HTTP request with all its components
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# Request is a value object that holds the URL, method, headers,
|
|
9
|
+
# query parameters, and body for an HTTP request.
|
|
9
10
|
#
|
|
10
|
-
|
|
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
|
|
13
|
+
# Creates a new HTTP request with the specified options
|
|
46
14
|
#
|
|
47
|
-
# @param
|
|
48
|
-
# @
|
|
49
|
-
# @
|
|
50
|
-
# @
|
|
51
|
-
# @
|
|
52
|
-
# @
|
|
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
|
|
23
|
+
# @return [Request] A new request instance
|
|
55
24
|
#
|
|
56
25
|
def initialize(**options)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
37
|
+
# Returns the Content-Type header value
|
|
70
38
|
#
|
|
71
|
-
# @return [
|
|
39
|
+
# @return [String, nil] The content type or nil if not set
|
|
72
40
|
#
|
|
73
|
-
def
|
|
74
|
-
|
|
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
|
-
#
|
|
46
|
+
# Returns whether this request has a JSON content type
|
|
83
47
|
#
|
|
84
|
-
#
|
|
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
|
-
|
|
50
|
+
def json?
|
|
51
|
+
content_type == "application/json"
|
|
52
|
+
end
|
|
53
|
+
|
|
90
54
|
#
|
|
91
|
-
#
|
|
55
|
+
# Converts the request to a hash with stringified verb
|
|
92
56
|
#
|
|
93
|
-
# @
|
|
57
|
+
# @return [Hash] Hash representation of the request
|
|
94
58
|
#
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
data/lib/spec_forge/http/verb.rb
CHANGED
|
@@ -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
|
#
|
data/lib/spec_forge/http.rb
CHANGED
|
@@ -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
|