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,503 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
#
|
|
6
|
+
# Handles formatted output for forge execution
|
|
7
|
+
#
|
|
8
|
+
# Display manages the console output during test runs, adapting
|
|
9
|
+
# its verbosity based on the configured level (0-3). It formats
|
|
10
|
+
# step headers, action indicators, expectation results, and
|
|
11
|
+
# failure summaries.
|
|
12
|
+
#
|
|
13
|
+
class Display
|
|
14
|
+
# Maximum line length for output formatting
|
|
15
|
+
#
|
|
16
|
+
# @return [Integer]
|
|
17
|
+
LINE_LENGTH = 120
|
|
18
|
+
|
|
19
|
+
# @return [Integer] Current verbosity level (0-3)
|
|
20
|
+
attr_reader :verbosity_level
|
|
21
|
+
|
|
22
|
+
#
|
|
23
|
+
# Creates a new display handler with the specified verbosity level
|
|
24
|
+
#
|
|
25
|
+
# @param verbosity_level [Integer] Output verbosity (0=minimal, 1=verbose, 2=debug, 3=trace)
|
|
26
|
+
#
|
|
27
|
+
# @return [Display] A new display instance
|
|
28
|
+
#
|
|
29
|
+
def initialize(verbosity_level: 0)
|
|
30
|
+
@verbosity_level = verbosity_level || 0
|
|
31
|
+
@color = Pastel.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#
|
|
35
|
+
# Returns whether display is in default (minimal) mode
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean] True if verbosity is 0
|
|
38
|
+
#
|
|
39
|
+
def default_mode?
|
|
40
|
+
verbosity_level == 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#
|
|
44
|
+
# Returns whether display is in verbose mode or higher
|
|
45
|
+
#
|
|
46
|
+
# @return [Boolean] True if verbosity is 1 or higher
|
|
47
|
+
#
|
|
48
|
+
def verbose?
|
|
49
|
+
verbosity_level >= 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#
|
|
53
|
+
# Returns whether display is in very verbose (debug) mode or higher
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean] True if verbosity is 2 or higher
|
|
56
|
+
#
|
|
57
|
+
def very_verbose?
|
|
58
|
+
verbosity_level >= 2
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
# Returns whether display is in maximum verbose (trace) mode
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] True if verbosity is 3 or higher
|
|
65
|
+
#
|
|
66
|
+
def max_verbose?
|
|
67
|
+
verbosity_level >= 3
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# Outputs a blank line for visual separation
|
|
72
|
+
#
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
def empty_line
|
|
76
|
+
puts ""
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
#
|
|
80
|
+
# Displays an action message with optional symbol indicator
|
|
81
|
+
#
|
|
82
|
+
# Only shown when verbosity is above default (0). Used to indicate
|
|
83
|
+
# step actions like callbacks, requests, and store operations.
|
|
84
|
+
#
|
|
85
|
+
# @param message [String] The action message to display
|
|
86
|
+
# @param options [Hash] Formatting options passed to #format
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
#
|
|
90
|
+
def action(message, **options)
|
|
91
|
+
return if default_mode?
|
|
92
|
+
|
|
93
|
+
puts format(message, indent: 1, **options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#
|
|
97
|
+
# Displays a passing expectation indicator (green dot)
|
|
98
|
+
#
|
|
99
|
+
# @param message [String] The expectation message (unused in default mode)
|
|
100
|
+
# @param indent [Integer] Indentation level
|
|
101
|
+
#
|
|
102
|
+
# @return [void]
|
|
103
|
+
#
|
|
104
|
+
def expectation_passed(message, indent: 0)
|
|
105
|
+
return if verbose?
|
|
106
|
+
|
|
107
|
+
print @color.green(".")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#
|
|
111
|
+
# Displays a failing expectation indicator (red F)
|
|
112
|
+
#
|
|
113
|
+
# @param message [String] The expectation message (unused in default mode)
|
|
114
|
+
# @param indent [Integer] Indentation level
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
#
|
|
118
|
+
def expectation_failed(message, indent: 0)
|
|
119
|
+
return if verbose?
|
|
120
|
+
|
|
121
|
+
print @color.red("F")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
#
|
|
125
|
+
# Displays the result summary for a completed expectation
|
|
126
|
+
#
|
|
127
|
+
# @param failed_examples [Array] List of failed RSpec examples
|
|
128
|
+
# @param total_count [Integer] Total number of assertions in the expectation
|
|
129
|
+
# @param index [Integer] The expectation index (1-based)
|
|
130
|
+
# @param show_index [Boolean] Whether to display the index prefix
|
|
131
|
+
#
|
|
132
|
+
# @return [void]
|
|
133
|
+
#
|
|
134
|
+
def expectation_finished(failed_examples:, total_count:, index: 0, show_index: false)
|
|
135
|
+
return if default_mode?
|
|
136
|
+
|
|
137
|
+
failed_count = failed_examples.size
|
|
138
|
+
|
|
139
|
+
print format_with_indent("#{index}: ", indent: 1) if show_index
|
|
140
|
+
|
|
141
|
+
if failed_count == 0
|
|
142
|
+
action(
|
|
143
|
+
"(#{total_count}/#{total_count} passed)",
|
|
144
|
+
symbol: :success,
|
|
145
|
+
symbol_styles: :green,
|
|
146
|
+
indent: show_index ? 0 : 1
|
|
147
|
+
)
|
|
148
|
+
else
|
|
149
|
+
action(
|
|
150
|
+
"(#{failed_count}/#{total_count} failed)",
|
|
151
|
+
symbol: :error,
|
|
152
|
+
symbol_styles: :red,
|
|
153
|
+
indent: show_index ? 0 : 1
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
return if failed_examples.blank?
|
|
158
|
+
|
|
159
|
+
puts ""
|
|
160
|
+
|
|
161
|
+
failed_examples.each do |example|
|
|
162
|
+
message = example[:exception][:message].strip.prepend("\n")
|
|
163
|
+
|
|
164
|
+
puts format_with_indent("#{example[:description]} #{@color.red(message)}", indent: 3)
|
|
165
|
+
# puts JSON.pretty_generate(example[:exception][:backtrace]) # DEBUG
|
|
166
|
+
|
|
167
|
+
puts ""
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
#
|
|
172
|
+
# Called when the forge run begins
|
|
173
|
+
#
|
|
174
|
+
# Displays a header line indicating the forge has started.
|
|
175
|
+
# Only shown in verbose modes.
|
|
176
|
+
#
|
|
177
|
+
# @param forge [Forge] The forge instance starting
|
|
178
|
+
#
|
|
179
|
+
# @return [void]
|
|
180
|
+
#
|
|
181
|
+
def forge_start(forge)
|
|
182
|
+
return if default_mode?
|
|
183
|
+
|
|
184
|
+
line = "#{@color.magenta("[forge]")} Ignited"
|
|
185
|
+
filler = @color.magenta("━" * (LINE_LENGTH - 15)) # [forge] ignited
|
|
186
|
+
|
|
187
|
+
puts "#{line} #{filler}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
#
|
|
191
|
+
# Called when a blueprint begins execution
|
|
192
|
+
#
|
|
193
|
+
# Displays a header line with the blueprint name.
|
|
194
|
+
# Only shown in verbose modes.
|
|
195
|
+
#
|
|
196
|
+
# @param blueprint [Blueprint] The blueprint starting
|
|
197
|
+
#
|
|
198
|
+
# @return [void]
|
|
199
|
+
#
|
|
200
|
+
def blueprint_start(blueprint)
|
|
201
|
+
return if default_mode?
|
|
202
|
+
|
|
203
|
+
visual_length = "[#{blueprint.name}] Setup".size
|
|
204
|
+
line = "#{@color.bright_blue("[#{blueprint.name}]")} Setup"
|
|
205
|
+
filler = @color.bright_blue("━" * (LINE_LENGTH - visual_length))
|
|
206
|
+
|
|
207
|
+
puts "#{line} #{filler}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
#
|
|
211
|
+
# Called when a step begins execution
|
|
212
|
+
#
|
|
213
|
+
# @param step [Step] The step starting
|
|
214
|
+
#
|
|
215
|
+
# @return [void]
|
|
216
|
+
#
|
|
217
|
+
def step_start(step)
|
|
218
|
+
return if default_mode?
|
|
219
|
+
|
|
220
|
+
line_number = step.source.line_number.to_s.rjust(2, "0")
|
|
221
|
+
line = "#{step.source.file_name}:#{line_number}"
|
|
222
|
+
|
|
223
|
+
if step.included_by.present?
|
|
224
|
+
line_number = step.included_by.line_number.to_s.rjust(2, "0")
|
|
225
|
+
line = "#{step.included_by.file_name}:#{line_number} → #{line}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
visual_length = line.size + 2
|
|
229
|
+
line = @color.cyan("[#{line}]")
|
|
230
|
+
|
|
231
|
+
filler_size = LINE_LENGTH - visual_length
|
|
232
|
+
|
|
233
|
+
# +1 offset to match forge/blueprint headers
|
|
234
|
+
if step.name.present?
|
|
235
|
+
name = step.name
|
|
236
|
+
filler_size -= (name.size + 1)
|
|
237
|
+
else
|
|
238
|
+
name = @color.dim("(unnamed)")
|
|
239
|
+
filler_size -= 10 # (unnamed) + 1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
filler = @color.cyan("━" * filler_size)
|
|
243
|
+
|
|
244
|
+
puts "#{line} #{name} #{filler}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
#
|
|
248
|
+
# Called when a step finishes execution
|
|
249
|
+
#
|
|
250
|
+
# @param forge [Forge] The forge instance
|
|
251
|
+
# @param step [Step] The step that finished
|
|
252
|
+
# @param error [Exception, nil] Any error that occurred
|
|
253
|
+
#
|
|
254
|
+
# @return [void]
|
|
255
|
+
#
|
|
256
|
+
def step_end(forge, step, error: nil)
|
|
257
|
+
return unless verbose?
|
|
258
|
+
|
|
259
|
+
puts ""
|
|
260
|
+
|
|
261
|
+
if max_verbose? || (very_verbose? && error)
|
|
262
|
+
indent = error ? 3 : 1
|
|
263
|
+
|
|
264
|
+
details = format_debug_details(forge, step, indent:)
|
|
265
|
+
return if details.blank?
|
|
266
|
+
|
|
267
|
+
if error
|
|
268
|
+
puts format_with_indent(@color.red(error.message), indent:) unless error.is_a?(Error::ExpectationFailure)
|
|
269
|
+
puts ""
|
|
270
|
+
puts format_with_indent(@color.dim("━" * (LINE_LENGTH * 0.75)), indent:)
|
|
271
|
+
puts ""
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
puts details
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
#
|
|
279
|
+
# Called when a blueprint finishes execution
|
|
280
|
+
#
|
|
281
|
+
# @param blueprint [Blueprint] The blueprint that finished
|
|
282
|
+
# @param success [Boolean] Whether all steps passed
|
|
283
|
+
#
|
|
284
|
+
# @return [void]
|
|
285
|
+
#
|
|
286
|
+
def blueprint_end(blueprint, success: true)
|
|
287
|
+
return if default_mode?
|
|
288
|
+
|
|
289
|
+
style = success ? :bright_green : :bright_red
|
|
290
|
+
|
|
291
|
+
visual_length = "[#{blueprint.name}] Cleanup".size
|
|
292
|
+
line = "#{@color.decorate("[#{blueprint.name}]", style)} Cleanup"
|
|
293
|
+
length = LINE_LENGTH - visual_length
|
|
294
|
+
|
|
295
|
+
filler = @color.decorate("━" * length, style)
|
|
296
|
+
|
|
297
|
+
puts "#{line} #{filler}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
#
|
|
301
|
+
# Called when the entire forge run completes
|
|
302
|
+
#
|
|
303
|
+
# @param forge [Forge] The forge instance
|
|
304
|
+
#
|
|
305
|
+
# @return [void]
|
|
306
|
+
#
|
|
307
|
+
def forge_end(forge)
|
|
308
|
+
return if default_mode?
|
|
309
|
+
|
|
310
|
+
line = "#{@color.magenta("[forge]")} Quenched"
|
|
311
|
+
filler = @color.magenta("━" * (LINE_LENGTH - 16))
|
|
312
|
+
|
|
313
|
+
puts "#{line} #{filler}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
#
|
|
317
|
+
# Displays final statistics and any failures from the forge run
|
|
318
|
+
#
|
|
319
|
+
# Shows failure details (if any), summary counts for blueprints,
|
|
320
|
+
# steps, expectations, and failures, plus total execution time.
|
|
321
|
+
#
|
|
322
|
+
# @param forge [Forge] The completed forge instance
|
|
323
|
+
#
|
|
324
|
+
# @return [void]
|
|
325
|
+
#
|
|
326
|
+
def stats(forge)
|
|
327
|
+
puts ""
|
|
328
|
+
|
|
329
|
+
if forge.failures.size > 0
|
|
330
|
+
puts "Failures:\n\n"
|
|
331
|
+
puts format_failures(forge.failures)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
puts format_stats(forge)
|
|
335
|
+
puts ""
|
|
336
|
+
puts @color.dim("Finished in #{sprintf("%.2g", forge.timer.time_elapsed)}s")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
private
|
|
340
|
+
|
|
341
|
+
def format(message, indent: 0, message_styles: nil, symbol: nil, symbol_styles: nil)
|
|
342
|
+
symbol =
|
|
343
|
+
case symbol
|
|
344
|
+
when :right_arrow
|
|
345
|
+
"→"
|
|
346
|
+
when :flag
|
|
347
|
+
"⚑"
|
|
348
|
+
when :checkmark, :success
|
|
349
|
+
"✓"
|
|
350
|
+
when :x, :error
|
|
351
|
+
"✗"
|
|
352
|
+
else
|
|
353
|
+
""
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
if symbol_styles.present?
|
|
357
|
+
symbol = @color.decorate(symbol, *Array.wrap(symbol_styles))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
if message_styles.present?
|
|
361
|
+
message = @color.decorate(message, *Array.wrap(message_styles))
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
message = "#{symbol} #{message}" if symbol.present?
|
|
365
|
+
|
|
366
|
+
format_with_indent(message, indent:)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def format_with_indent(message, indent: 0)
|
|
370
|
+
indent = (indent == 0) ? "" : " " * (indent * 2) # 2 spaces
|
|
371
|
+
|
|
372
|
+
"#{indent}#{message.gsub("\n", "\n#{indent}")}"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def format_failures(failures)
|
|
376
|
+
output = []
|
|
377
|
+
|
|
378
|
+
failures
|
|
379
|
+
.group_by_key(:step)
|
|
380
|
+
.each_with_index do |(step, failures), index|
|
|
381
|
+
line = step.source.line_number.to_s.rjust(2, "0")
|
|
382
|
+
location = @color.bright_blue("#{index + 1}) [#{step.source.file_name}:#{line}]")
|
|
383
|
+
|
|
384
|
+
output << format_with_indent("#{location} #{@color.white(step.name)}", indent: 1)
|
|
385
|
+
output << ""
|
|
386
|
+
|
|
387
|
+
failures.each do |failure|
|
|
388
|
+
example = failure[:example]
|
|
389
|
+
message = example[:exception][:message].strip.prepend("\n")
|
|
390
|
+
|
|
391
|
+
output << format_with_indent("#{example[:description]} #{@color.red(message)}", indent: 3)
|
|
392
|
+
output << ""
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
output << ""
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
output.join("\n")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def format_stats(forge)
|
|
402
|
+
stats = forge.stats
|
|
403
|
+
|
|
404
|
+
blueprint_count = stats[:blueprints]
|
|
405
|
+
step_count = stats[:steps]
|
|
406
|
+
passed_count = stats[:passed]
|
|
407
|
+
failures_count = stats[:failed]
|
|
408
|
+
|
|
409
|
+
blueprints = "#{blueprint_count} #{"blueprint".pluralize(blueprint_count)}"
|
|
410
|
+
steps = "#{step_count} #{"step".pluralize(step_count)}"
|
|
411
|
+
passed = "#{passed_count} #{"expectation".pluralize(passed_count)}"
|
|
412
|
+
failures = "#{failures_count} #{"failure".pluralize(failures_count)}"
|
|
413
|
+
|
|
414
|
+
message = "#{blueprints}, #{steps}, #{passed}, #{failures}"
|
|
415
|
+
|
|
416
|
+
if failures_count > 0
|
|
417
|
+
@color.red(message)
|
|
418
|
+
else
|
|
419
|
+
@color.green(message)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def format_schema_for_display(schema)
|
|
424
|
+
return if schema.blank?
|
|
425
|
+
|
|
426
|
+
case schema
|
|
427
|
+
when Hash
|
|
428
|
+
if schema[:pattern]
|
|
429
|
+
# Array with pattern - show type + pattern structure
|
|
430
|
+
{
|
|
431
|
+
type: Type.to_string(*schema[:type]),
|
|
432
|
+
pattern: format_schema_for_display(schema[:pattern])
|
|
433
|
+
}
|
|
434
|
+
elsif schema[:structure]
|
|
435
|
+
# Hash with structure - just recurse into the fields (no type: hash clutter)
|
|
436
|
+
schema[:structure].transform_values { |field| format_schema_for_display(field) }
|
|
437
|
+
elsif schema[:type]
|
|
438
|
+
# Simple field - just convert the type
|
|
439
|
+
Type.to_string(*schema[:type])
|
|
440
|
+
else
|
|
441
|
+
# Unknown structure, pass through as-is
|
|
442
|
+
schema
|
|
443
|
+
end
|
|
444
|
+
else
|
|
445
|
+
# Not a hash, return as-is
|
|
446
|
+
schema
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def format_debug_details(forge, step, indent: 0)
|
|
451
|
+
output = []
|
|
452
|
+
|
|
453
|
+
variables = forge.variables.to_hash.symbolize_keys
|
|
454
|
+
if (request = variables.delete(:request))
|
|
455
|
+
output << format_with_indent("Request:", indent:)
|
|
456
|
+
output << format_with_indent(request.to_h.to_yaml(stringify_names: true).sub("---\n", ""), indent: indent + 1)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
if (response = variables.delete(:response))
|
|
460
|
+
output << format_with_indent("Response:", indent:)
|
|
461
|
+
output << format_with_indent(response.to_h.to_yaml(stringify_names: true).sub("---\n", ""), indent: indent + 1)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
if variables.present?
|
|
465
|
+
output << format_with_indent("Variables:", indent:)
|
|
466
|
+
output << format_with_indent(variables.to_h.to_yaml(stringify_names: true).sub("---\n", ""), indent: indent + 1)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
if (expects = step.expects) && expects.present?
|
|
470
|
+
expectations = expects.map do |expect|
|
|
471
|
+
expect = expect.to_h
|
|
472
|
+
|
|
473
|
+
if (schema = expect.dig(:json, :schema)) && schema.present?
|
|
474
|
+
expect[:json][:schema] = format_schema_for_display(schema)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
expect = expect.compact_blank.deep_stringify_keys
|
|
478
|
+
|
|
479
|
+
expect.deep_transform_values do |value|
|
|
480
|
+
value = Attribute.resolve_as_matcher_proc.call(value)
|
|
481
|
+
|
|
482
|
+
if value.respond_to?(:description)
|
|
483
|
+
value.description
|
|
484
|
+
else
|
|
485
|
+
value
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
if expectations.size > 0
|
|
491
|
+
output << format_with_indent("Expectations:", indent:)
|
|
492
|
+
output << format_with_indent(
|
|
493
|
+
expectations.to_yaml(stringify_names: true).sub("---\n", ""),
|
|
494
|
+
indent: indent + 1
|
|
495
|
+
)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
output.join("\n")
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
#
|
|
6
|
+
# Executes lifecycle hooks at various points during forge execution
|
|
7
|
+
#
|
|
8
|
+
# Provides class methods for triggering before/after hooks at the
|
|
9
|
+
# forge, blueprint, and step levels.
|
|
10
|
+
#
|
|
11
|
+
class Hooks
|
|
12
|
+
class << self
|
|
13
|
+
#
|
|
14
|
+
# Executes before-forge hooks
|
|
15
|
+
#
|
|
16
|
+
# @param forge [Forge] The forge instance
|
|
17
|
+
#
|
|
18
|
+
# @return [void]
|
|
19
|
+
#
|
|
20
|
+
def before_forge(forge)
|
|
21
|
+
hooks = forge.hooks[:before]
|
|
22
|
+
return if hooks.blank?
|
|
23
|
+
|
|
24
|
+
context = SpecForge::Forge.context.with(forge:)
|
|
25
|
+
run(forge, context, hooks)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#
|
|
29
|
+
# Executes before-blueprint hooks
|
|
30
|
+
#
|
|
31
|
+
# @param forge [Forge] The forge instance
|
|
32
|
+
# @param blueprint [Blueprint] The blueprint about to execute
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
#
|
|
36
|
+
def before_blueprint(forge, blueprint)
|
|
37
|
+
hooks = blueprint.hooks[:before]
|
|
38
|
+
return if hooks.blank?
|
|
39
|
+
|
|
40
|
+
context = SpecForge::Forge.context.with(forge:, blueprint:)
|
|
41
|
+
run(forge, context, hooks, trailing_newline: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#
|
|
45
|
+
# Executes before-step hooks
|
|
46
|
+
#
|
|
47
|
+
# @param forge [Forge] The forge instance
|
|
48
|
+
# @param step [Step] The step about to execute
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
#
|
|
52
|
+
def before_step(forge, blueprint, step)
|
|
53
|
+
hooks = step.hooks[:before]
|
|
54
|
+
return if hooks.blank?
|
|
55
|
+
|
|
56
|
+
context = SpecForge::Forge.context.with(forge:, blueprint:, step:)
|
|
57
|
+
run(forge, context, hooks, trailing_newline: true)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#
|
|
61
|
+
# Executes after-step hooks
|
|
62
|
+
#
|
|
63
|
+
# @param forge [Forge] The forge instance
|
|
64
|
+
# @param step [Step] The step that finished
|
|
65
|
+
# @param error [Exception, nil] Any error that occurred
|
|
66
|
+
#
|
|
67
|
+
# @return [void]
|
|
68
|
+
#
|
|
69
|
+
def after_step(forge, blueprint, step, error: nil)
|
|
70
|
+
hooks = step.hooks[:after]
|
|
71
|
+
return if hooks.blank?
|
|
72
|
+
|
|
73
|
+
context = SpecForge::Forge.context.with(forge:, blueprint:, step:, error:)
|
|
74
|
+
run(forge, context, hooks, leading_newline: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#
|
|
78
|
+
# Executes after-blueprint hooks
|
|
79
|
+
#
|
|
80
|
+
# @param forge [Forge] The forge instance
|
|
81
|
+
# @param blueprint [Blueprint] The blueprint that finished
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
#
|
|
85
|
+
def after_blueprint(forge, blueprint)
|
|
86
|
+
hooks = blueprint.hooks[:after]
|
|
87
|
+
return if hooks.blank?
|
|
88
|
+
|
|
89
|
+
context = SpecForge::Forge.context.with(forge:, blueprint:)
|
|
90
|
+
run(forge, context, hooks)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
#
|
|
94
|
+
# Executes after-forge hooks
|
|
95
|
+
#
|
|
96
|
+
# @param forge [Forge] The forge instance
|
|
97
|
+
#
|
|
98
|
+
# @return [void]
|
|
99
|
+
#
|
|
100
|
+
def after_forge(forge)
|
|
101
|
+
hooks = forge.hooks[:after]
|
|
102
|
+
return if hooks.blank?
|
|
103
|
+
|
|
104
|
+
context = SpecForge::Forge.context.with(forge:)
|
|
105
|
+
run(forge, context, hooks)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def run(forge, context, calls, leading_newline: false, trailing_newline: false)
|
|
111
|
+
forge.display.empty_line if leading_newline
|
|
112
|
+
|
|
113
|
+
calls.each do |call|
|
|
114
|
+
callback_name = call.callback_name
|
|
115
|
+
arguments = call.arguments
|
|
116
|
+
|
|
117
|
+
forge.display.action(
|
|
118
|
+
"Call #{callback_name}",
|
|
119
|
+
message_styles: :dim,
|
|
120
|
+
symbol: :checkmark, symbol_styles: :dim
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
forge.callbacks.run(callback_name, context, arguments)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
forge.display.empty_line if trailing_newline
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpecForge
|
|
4
|
+
class Forge
|
|
5
|
+
class Runner
|
|
6
|
+
#
|
|
7
|
+
# IO-like object that stores writes in an array instead of a string buffer
|
|
8
|
+
#
|
|
9
|
+
# Used with RSpec's JSON formatter to capture each JSON output as a separate
|
|
10
|
+
# entry rather than concatenating them into one long string.
|
|
11
|
+
#
|
|
12
|
+
class ArrayIO
|
|
13
|
+
#
|
|
14
|
+
# All entries written to this IO
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<String>]
|
|
17
|
+
#
|
|
18
|
+
attr_reader :entries
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] Whether the IO has been closed
|
|
21
|
+
attr_predicate :closed
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@entries = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# Writes a string as a new entry
|
|
29
|
+
#
|
|
30
|
+
# @param string [String] The string to write
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
#
|
|
34
|
+
def write(string)
|
|
35
|
+
@entries << string
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#
|
|
39
|
+
# Writes strings with newlines appended
|
|
40
|
+
#
|
|
41
|
+
# @param strings [Array<String>] Strings to write
|
|
42
|
+
#
|
|
43
|
+
# @return [void]
|
|
44
|
+
#
|
|
45
|
+
def puts(*strings)
|
|
46
|
+
strings.each { |string| write(string + "\n") }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#
|
|
50
|
+
# Appends a string (alias for write)
|
|
51
|
+
#
|
|
52
|
+
# @param string [String] The string to append
|
|
53
|
+
#
|
|
54
|
+
# @return [void]
|
|
55
|
+
#
|
|
56
|
+
def <<(string)
|
|
57
|
+
write(string)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#
|
|
61
|
+
# Clears all entries
|
|
62
|
+
#
|
|
63
|
+
# @return [Integer] Always returns 0
|
|
64
|
+
#
|
|
65
|
+
def flush
|
|
66
|
+
@entries.clear
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# Marks the IO as closed
|
|
72
|
+
#
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
def close
|
|
76
|
+
@closed = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|