spec_forge 0.7.1 → 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 +75 -1
  3. data/README.md +124 -202
  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 +5 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +209 -79
  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 +21 -5
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
  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 -146
  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 -76
  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 -181
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -215
  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
@@ -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