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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
class Runner
|
|
6
|
+
#
|
|
7
|
+
# Validates JSON content against expected matchers
|
|
8
|
+
#
|
|
9
|
+
# Recursively walks through the response body and expected values,
|
|
10
|
+
# running RSpec matchers at each leaf node and collecting failures.
|
|
11
|
+
#
|
|
12
|
+
class ContentValidator
|
|
13
|
+
#
|
|
14
|
+
# Creates a new content validator
|
|
15
|
+
#
|
|
16
|
+
# @param data [Hash, Array] The response data to validate
|
|
17
|
+
# @param expected [Hash, Array] The expected content matchers
|
|
18
|
+
#
|
|
19
|
+
# @return [ContentValidator] A new validator instance
|
|
20
|
+
#
|
|
21
|
+
def initialize(data, expected)
|
|
22
|
+
@data = data
|
|
23
|
+
@expected = expected
|
|
24
|
+
@failures = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# Validates the data against expected content matchers
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
# @raise [Error::ContentValidationFailure] If validation fails
|
|
33
|
+
#
|
|
34
|
+
def validate!
|
|
35
|
+
check_content(@data, @expected, path: "")
|
|
36
|
+
|
|
37
|
+
raise Error::ContentValidationFailure.new(@failures) if @failures.any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def failure!(path, message)
|
|
43
|
+
@failures << {path:, message:}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_content(data, expected, path:)
|
|
47
|
+
case expected
|
|
48
|
+
when Hash
|
|
49
|
+
check_hash(data, expected, path:)
|
|
50
|
+
when Array
|
|
51
|
+
check_array(data, expected, path:)
|
|
52
|
+
else
|
|
53
|
+
run_matcher(data, expected, path:)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_hash(data, expected, path:)
|
|
58
|
+
expected.each do |key, expected_value|
|
|
59
|
+
new_path = path.empty? ? ".#{key}" : "#{path}.#{key}"
|
|
60
|
+
|
|
61
|
+
actual_key = [key.to_sym, key.to_s].detect { |k| data.respond_to?(:key?) && data.key?(k) }
|
|
62
|
+
actual_value = data[actual_key]
|
|
63
|
+
|
|
64
|
+
if actual_value.nil? && actual_key.nil?
|
|
65
|
+
failure!(new_path, "key not found")
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
check_content(actual_value, expected_value, path: new_path)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_array(data, expected, path:)
|
|
74
|
+
if !data.is_a?(Array)
|
|
75
|
+
failure!(path, "expected array, got #{data.class}")
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
expected.each_with_index do |expected_item, index|
|
|
80
|
+
check_content(data[index], expected_item, path: "#{path}[#{index}]")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def run_matcher(data, matcher, path:)
|
|
85
|
+
return if matcher.matches?(data)
|
|
86
|
+
|
|
87
|
+
failure!(path, matcher.failure_message.lstrip)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
class Runner
|
|
6
|
+
#
|
|
7
|
+
# Validates HTTP response headers against expected matchers
|
|
8
|
+
#
|
|
9
|
+
# Performs case-insensitive header name matching and runs
|
|
10
|
+
# RSpec matchers against header values.
|
|
11
|
+
#
|
|
12
|
+
class HeaderValidator
|
|
13
|
+
#
|
|
14
|
+
# Creates a new header validator
|
|
15
|
+
#
|
|
16
|
+
# @param headers [Hash] The response headers to validate
|
|
17
|
+
# @param expected [Hash] The expected header matchers
|
|
18
|
+
#
|
|
19
|
+
# @return [HeaderValidator] A new validator instance
|
|
20
|
+
#
|
|
21
|
+
def initialize(headers, expected)
|
|
22
|
+
@headers = headers
|
|
23
|
+
@expected = expected
|
|
24
|
+
@failures = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# Validates the headers against expected matchers
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
#
|
|
32
|
+
# @raise [Error::HeaderValidationFailure] If validation fails
|
|
33
|
+
#
|
|
34
|
+
def validate!
|
|
35
|
+
@expected.each do |key, matcher|
|
|
36
|
+
actual_key = find_header_key(key)
|
|
37
|
+
|
|
38
|
+
if actual_key.nil?
|
|
39
|
+
failure!(key, "header not found")
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
actual_value = @headers[actual_key]
|
|
44
|
+
next if matcher.matches?(actual_value)
|
|
45
|
+
|
|
46
|
+
failure!(key, matcher.failure_message.lstrip)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
raise Error::HeaderValidationFailure.new(@failures) if @failures.any?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def find_header_key(key)
|
|
55
|
+
return key if @headers.key?(key)
|
|
56
|
+
|
|
57
|
+
@headers.keys.find { |k| k.to_s.downcase == key.to_s.downcase }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure!(header, message)
|
|
61
|
+
@failures << {header:, message:}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
class Runner
|
|
6
|
+
#
|
|
7
|
+
# RSpec formatter listener for tracking test results
|
|
8
|
+
#
|
|
9
|
+
# Receives notifications from RSpec when examples pass or fail,
|
|
10
|
+
# updating the forge's statistics and display accordingly.
|
|
11
|
+
#
|
|
12
|
+
class Reporter
|
|
13
|
+
#
|
|
14
|
+
# Creates a new reporter for the given forge instance
|
|
15
|
+
#
|
|
16
|
+
# @param forge [Forge, nil] The forge instance to report to
|
|
17
|
+
#
|
|
18
|
+
# @return [Reporter] A new reporter instance
|
|
19
|
+
#
|
|
20
|
+
def initialize(forge = nil)
|
|
21
|
+
@forge = forge
|
|
22
|
+
@stats = forge&.stats
|
|
23
|
+
@display = forge&.display
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Called when an RSpec example fails
|
|
28
|
+
#
|
|
29
|
+
# @param notification [RSpec::Core::Notifications::ExampleNotification] The failure notification
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
#
|
|
33
|
+
def example_failed(notification)
|
|
34
|
+
return if @stats.nil? || @display.nil?
|
|
35
|
+
|
|
36
|
+
@stats[:failed] += 1
|
|
37
|
+
@display.expectation_failed(notification.example.description, indent: 1)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#
|
|
41
|
+
# Called when an RSpec example passes
|
|
42
|
+
#
|
|
43
|
+
# @param notification [RSpec::Core::Notifications::ExampleNotification] The success notification
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
#
|
|
47
|
+
def example_passed(notification)
|
|
48
|
+
return if @stats.nil? || @display.nil?
|
|
49
|
+
|
|
50
|
+
@stats[:passed] += 1
|
|
51
|
+
@display.expectation_passed(notification.example.description, indent: 1)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
class Runner
|
|
6
|
+
#
|
|
7
|
+
# Validates JSON structure against a schema definition
|
|
8
|
+
#
|
|
9
|
+
# Validates that response data matches the expected types and structure
|
|
10
|
+
# defined in shape: or schema: blocks. Supports nested objects, arrays
|
|
11
|
+
# with patterns, and nullable types.
|
|
12
|
+
#
|
|
13
|
+
class SchemaValidator
|
|
14
|
+
#
|
|
15
|
+
# Creates a new schema validator
|
|
16
|
+
#
|
|
17
|
+
# @param data [Hash, Array] The response data to validate
|
|
18
|
+
# @param schema [Hash] The schema definition to validate against
|
|
19
|
+
#
|
|
20
|
+
# @return [SchemaValidator] A new validator instance
|
|
21
|
+
#
|
|
22
|
+
def initialize(data, schema)
|
|
23
|
+
@data = data
|
|
24
|
+
@schema = schema
|
|
25
|
+
@failures = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#
|
|
29
|
+
# Validates the data against the schema definition
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
#
|
|
33
|
+
# @raise [Error::SchemaValidationFailure] If validation fails
|
|
34
|
+
#
|
|
35
|
+
def validate!
|
|
36
|
+
check_schema(@data, @schema, path: "")
|
|
37
|
+
|
|
38
|
+
raise Error::SchemaValidationFailure.new(@failures) if @failures.size > 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def failure!(path, expected_type, actual_value)
|
|
44
|
+
@failures << {
|
|
45
|
+
path: path.empty? ? "root" : path,
|
|
46
|
+
expected_type:,
|
|
47
|
+
actual_value:,
|
|
48
|
+
actual_type: actual_value.class
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_schema(data, schema, path:)
|
|
53
|
+
check_type(data, schema[:type], path:)
|
|
54
|
+
check_structure(data, schema[:structure], path:) if schema[:structure]
|
|
55
|
+
check_pattern(data, schema[:pattern], path:) if schema[:pattern]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_type(data, expected_types, path:)
|
|
59
|
+
return if expected_types.any? { |type| data.is_a?(type) }
|
|
60
|
+
|
|
61
|
+
failure!(path, expected_types, data)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check_structure(data, schema, path:)
|
|
65
|
+
case schema
|
|
66
|
+
when Hash
|
|
67
|
+
check_hash_structure(data, schema, path:)
|
|
68
|
+
when Array
|
|
69
|
+
check_array_structure(data, schema, path:)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_pattern(data, schema, path:)
|
|
74
|
+
# Only arrays allowed
|
|
75
|
+
check_type(data, [Array], path:)
|
|
76
|
+
|
|
77
|
+
data.each_with_index do |value, index|
|
|
78
|
+
check_schema(value, schema, path: "#{path}[#{index}]")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_hash_structure(data, structure, path:)
|
|
83
|
+
structure.each do |key, expected|
|
|
84
|
+
check_hash_key(
|
|
85
|
+
data, key, expected,
|
|
86
|
+
path: path.empty? ? ".#{key}" : "#{path}.#{key}"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_hash_key(data, key, expected, path:)
|
|
92
|
+
actual_key = [key.to_sym, key.to_s].detect { |k| data.respond_to?(:key?) && data.key?(k) }
|
|
93
|
+
|
|
94
|
+
if actual_key
|
|
95
|
+
check_schema(data[actual_key], expected, path:)
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Key is missing - only fail if not optional
|
|
100
|
+
return if expected[:optional]
|
|
101
|
+
|
|
102
|
+
failure!(path, expected[:type], nil)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_array_structure(data, structure, path:)
|
|
106
|
+
structure.each_with_index do |expected, index|
|
|
107
|
+
check_schema(data[index], expected, path: "#{path}[#{index}]")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
#
|
|
6
|
+
# Executes expectations using RSpec as the underlying test framework
|
|
7
|
+
#
|
|
8
|
+
# Runner wraps RSpec to run individual expectation blocks, capturing
|
|
9
|
+
# results and formatting them for display. It creates isolated RSpec
|
|
10
|
+
# example groups for each expectation.
|
|
11
|
+
#
|
|
12
|
+
class Runner
|
|
13
|
+
# @return [ArrayIO] Output stream for RSpec formatter
|
|
14
|
+
attr_reader :output_io
|
|
15
|
+
|
|
16
|
+
# @return [StringIO] Error stream for RSpec formatter
|
|
17
|
+
attr_reader :error_io
|
|
18
|
+
|
|
19
|
+
#
|
|
20
|
+
# Creates a new RSpec runner with the specified CLI arguments
|
|
21
|
+
#
|
|
22
|
+
# @param cli_args [Array<String>] Command line arguments for RSpec configuration
|
|
23
|
+
#
|
|
24
|
+
# @return [Runner] A new runner instance
|
|
25
|
+
#
|
|
26
|
+
def initialize(cli_args = [])
|
|
27
|
+
options = RSpec::Core::ConfigurationOptions.new(cli_args)
|
|
28
|
+
|
|
29
|
+
@configuration = RSpec.configuration.deep_dup
|
|
30
|
+
@configuration.reset
|
|
31
|
+
|
|
32
|
+
@world = RSpec::Core::World.new
|
|
33
|
+
@runner = RSpec::Core::Runner.new(options, @configuration, @world)
|
|
34
|
+
|
|
35
|
+
@output_io = ArrayIO.new
|
|
36
|
+
@error_io = StringIO.new
|
|
37
|
+
@runner.configure(@error_io, @output_io)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#
|
|
41
|
+
# Runs an expectation and returns any failed examples
|
|
42
|
+
#
|
|
43
|
+
# @param forge [Forge] The forge instance
|
|
44
|
+
# @param step [Step] The current step
|
|
45
|
+
# @param expectation [Step::Expect] The expectation to run
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Hash>] List of failed examples (empty if all passed)
|
|
48
|
+
#
|
|
49
|
+
def run(forge, step, expectation)
|
|
50
|
+
configure_formatters(forge)
|
|
51
|
+
|
|
52
|
+
@runner.run_specs([create_example_group(forge, step, expectation)])
|
|
53
|
+
|
|
54
|
+
entry = @output_io.entries.last.to_h
|
|
55
|
+
entry[:examples].reject { |ex| ex[:status] == "passed" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def configure_formatters(forge)
|
|
61
|
+
# Resetting the configuration also means resetting the Formatters/Reporters.
|
|
62
|
+
@configuration.reset
|
|
63
|
+
@configuration.add_formatter(RSpec::Core::Formatters::JsonFormatter)
|
|
64
|
+
|
|
65
|
+
# Make sure to load a formatter first and register to its reporter.
|
|
66
|
+
# Otherwise RSpec will default the reporter.
|
|
67
|
+
@configuration.formatter_loader.reporter.register_listener(
|
|
68
|
+
Reporter.new(forge), :example_passed, :example_failed
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_example_group(forge, step, expectation)
|
|
73
|
+
RSpec::Core::ExampleGroup.describe(step.source.to_s, :spec_forge) do
|
|
74
|
+
let(:response) { forge.variables[:response] }
|
|
75
|
+
|
|
76
|
+
let(:headers) { response[:headers] }
|
|
77
|
+
let(:body) { response[:body].is_a?(Hash) ? response[:body].deep_symbolize_keys : response[:body] }
|
|
78
|
+
|
|
79
|
+
############################################################
|
|
80
|
+
# Status check
|
|
81
|
+
if (status_matcher = expectation.status_matcher)
|
|
82
|
+
it "Status" do
|
|
83
|
+
expect(response[:status]).to status_matcher
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
############################################################
|
|
88
|
+
# Headers check
|
|
89
|
+
if (headers_matcher = expectation.headers_matcher)
|
|
90
|
+
it "Headers" do
|
|
91
|
+
HeaderValidator.new(headers, headers_matcher).validate!
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
############################################################
|
|
96
|
+
# JSON checks
|
|
97
|
+
if (json_size_matcher = expectation.json_size_matcher)
|
|
98
|
+
it "JSON size" do
|
|
99
|
+
expect(body.size).to json_size_matcher
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if (schema_structure = expectation.json_schema)
|
|
104
|
+
it "JSON schema" do
|
|
105
|
+
SchemaValidator.new(body, schema_structure).validate!
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if (content_matcher = expectation.json_content_matcher)
|
|
110
|
+
it "JSON content" do
|
|
111
|
+
ContentValidator.new(body, content_matcher).validate!
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
#
|
|
6
|
+
# Simple timer for tracking execution duration
|
|
7
|
+
#
|
|
8
|
+
# Used to measure how long a forge run takes from start to finish.
|
|
9
|
+
#
|
|
10
|
+
class Timer
|
|
11
|
+
# @return [Time, nil] Time when the timer started
|
|
12
|
+
attr_reader :started_at
|
|
13
|
+
|
|
14
|
+
# @return [Time, nil] Time when the timer stopped
|
|
15
|
+
attr_reader :stopped_at
|
|
16
|
+
|
|
17
|
+
#
|
|
18
|
+
# Creates a new timer in the reset state
|
|
19
|
+
#
|
|
20
|
+
# @return [Timer] A new timer instance
|
|
21
|
+
#
|
|
22
|
+
def initialize
|
|
23
|
+
reset
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Resets the timer to its initial state
|
|
28
|
+
#
|
|
29
|
+
# @return [Timer] self for chaining
|
|
30
|
+
#
|
|
31
|
+
def reset
|
|
32
|
+
@started_at = nil
|
|
33
|
+
@stopped_at = nil
|
|
34
|
+
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Starts the timer
|
|
40
|
+
#
|
|
41
|
+
# @return [Timer] self for chaining
|
|
42
|
+
#
|
|
43
|
+
def start
|
|
44
|
+
reset
|
|
45
|
+
|
|
46
|
+
@started_at ||= Time.current
|
|
47
|
+
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#
|
|
52
|
+
# Stops the timer
|
|
53
|
+
#
|
|
54
|
+
# @return [Timer] self for chaining
|
|
55
|
+
#
|
|
56
|
+
def stop
|
|
57
|
+
return self if @started_at.nil?
|
|
58
|
+
|
|
59
|
+
@stopped_at ||= Time.current
|
|
60
|
+
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#
|
|
65
|
+
# Returns whether the timer has been started
|
|
66
|
+
#
|
|
67
|
+
# @return [Boolean] True if the timer has been started
|
|
68
|
+
#
|
|
69
|
+
def started?
|
|
70
|
+
!started_at.nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
#
|
|
74
|
+
# Returns whether the timer has been stopped
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] True if the timer has been stopped
|
|
77
|
+
#
|
|
78
|
+
def stopped?
|
|
79
|
+
!stopped_at.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#
|
|
83
|
+
# Returns the elapsed time in seconds
|
|
84
|
+
#
|
|
85
|
+
# @return [Float] Seconds elapsed since start (or 0 if not started)
|
|
86
|
+
#
|
|
87
|
+
def time_elapsed
|
|
88
|
+
return 0 if started_at.nil?
|
|
89
|
+
|
|
90
|
+
(stopped_at || Time.current) - started_at
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
#
|
|
6
|
+
# Hash-based storage for runtime variables
|
|
7
|
+
#
|
|
8
|
+
# Manages both static (global) variables that persist across blueprints
|
|
9
|
+
# and dynamic variables that are cleared between blueprints. Static
|
|
10
|
+
# variables are restored when clear is called.
|
|
11
|
+
#
|
|
12
|
+
class Variables < Hash
|
|
13
|
+
#
|
|
14
|
+
# Creates a new Variables hash with static and dynamic values
|
|
15
|
+
#
|
|
16
|
+
# @param static [Hash] Variables that persist across blueprint clears
|
|
17
|
+
# @param dynamic [Hash] Variables that are cleared between blueprints
|
|
18
|
+
#
|
|
19
|
+
# @return [Variables] A new variables instance
|
|
20
|
+
#
|
|
21
|
+
def initialize(static: {}, dynamic: {})
|
|
22
|
+
@static = static.deep_dup
|
|
23
|
+
|
|
24
|
+
merge!(@static, dynamic.deep_dup)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# Clears dynamic variables while preserving static ones
|
|
29
|
+
#
|
|
30
|
+
# @return [Variables] self
|
|
31
|
+
#
|
|
32
|
+
def clear
|
|
33
|
+
super
|
|
34
|
+
merge!(@static)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|