spec_forge 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f04f0ef7fc98b027990b272a524856731de03b46f9898430f6ccfa30bc2f3fe7
4
- data.tar.gz: a0130baec24c851c981aa76a4af01011e0944ece84965da37b9ad8630cc3dca3
3
+ metadata.gz: 658fd0830ce9179c1fe7ea880e2ed8196ccbc47bc8b244f6fc40c458ca900cca
4
+ data.tar.gz: 3658379f52f23ffa362ded1c50b28e052911b6602ac3a3bed02b1fd9c8ee334c
5
5
  SHA512:
6
- metadata.gz: 0b933a4b603f899c5140997c0e551d86c497671ed2447088489bb3f5dbdb056bb89757bc67bd4fc72033ddede7c54df64e9eb73b64d76092c6d5dbf997c31ab9
7
- data.tar.gz: 8b274025784f417bd7250f0fd716414c5511d392af4e2daabe1eb8fe1116f9416415f2b1d4c0495922fd7509b4a3b5fb648a86a4a62a6ced69b5392da906140b
6
+ metadata.gz: ebcdfd4e02d965049cabd1a18b7897665800dc8081149aa4554433e7083296f0121d76f4ec474b527fdd8df0da1c71170c9c10c5cff1a108f8704b7f14e7104f
7
+ data.tar.gz: ad3b84d27b9e7ab62dc902ad2a5d41a77670286aaba9a635ccebf5735061fa725e8d46daf62fd75562a35688d62b3dc595a597b9e38ec965f5273fdc27a2aabb
data/CHANGELOG.md CHANGED
@@ -13,6 +13,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
13
 
14
14
  ### Removed
15
15
 
16
+ ## [0.4.0] - 12025-02-22
17
+
18
+ ### Added
19
+
20
+ - Added support to run an individual file, spec, or expectation
21
+
22
+ ### Changed
23
+
24
+ - Updated `everythingrb` to 0.2
25
+ - Updated spec and factory templates
26
+ - Improved error reporting to use SpecForge's commands instead of RSpec's
27
+ - Updated `run` command's CLI documentation
28
+
29
+ ### Removed
30
+
31
+ - Removed support for ActiveSupport 7.0
32
+
33
+ ## [0.3.2] - 12025-02-20
34
+
35
+ ### Changed
36
+
37
+ - Moved Regex into its own Attribute class
38
+ - Fixed Regex parsing
39
+
16
40
  ## [0.3.0] - 12025-02-17
17
41
 
18
42
  ### Added
@@ -64,7 +88,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
64
88
 
65
89
  - Initial commit
66
90
 
67
- [unreleased]: https://github.com/itsthedevman/spec_forge/compare/v0.3.0...HEAD
91
+ [unreleased]: https://github.com/itsthedevman/spec_forge/compare/v0.4.0...HEAD
92
+ [0.4.0]: https://github.com/itsthedevman/spec_forge/compare/v0.3.2...v0.4.0
93
+ [0.3.2]: https://github.com/itsthedevman/spec_forge/compare/v0.3.0...v0.3.2
68
94
  [0.3.0]: https://github.com/itsthedevman/spec_forge/compare/v0.2.0...v0.3.0
69
95
  [0.2.0]: https://github.com/itsthedevman/spec_forge/compare/v0.1.0...v0.2.0
70
96
  [0.1.0]: https://github.com/itsthedevman/spec_forge/compare/a8a991c25dcbd472a5fd975e96aa223b05948618...v0.1.0
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # SpecForge
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/spec_forge.svg)](https://badge.fury.io/rb/spec_forge)
4
+ ![Ruby Version](https://img.shields.io/badge/ruby-3.3.7-ruby)
4
5
  [![Tests](https://github.com/itsthedevman/spec_forge/actions/workflows/main.yml/badge.svg)](https://github.com/itsthedevman/spec_forge/actions/workflows/main.yml)
5
- ![Ruby Version](https://img.shields.io/badge/ruby-3.3.6-ruby)
6
6
 
7
7
  Write API tests in YAML that read like documentation:
8
8
 
@@ -12,9 +12,9 @@ user_profile:
12
12
  expectations:
13
13
  - expect:
14
14
  status: 200
15
- json:
16
- name: kind_of.string
17
- email: /@/
15
+ json:
16
+ name: kind_of.string
17
+ email: /@/
18
18
  ```
19
19
 
20
20
  That's a complete test. No Ruby code, no configuration files, no HTTP client setup - just a clear description of what you're testing. Under the hood, you get all the power of RSpec's matchers, Faker's data generation, and FactoryBot's test objects.
@@ -41,24 +41,33 @@ Consider alternatives when you need:
41
41
  ## Roadmap
42
42
 
43
43
  Current development priorities:
44
- - [ ] Support for running individual specs
45
44
  - [ ] Array support for `json` expectations
46
45
  - [ ] Negated matchers: `matcher.not`
47
46
  - [ ] `create_list/build_list` factory strategies
48
47
  - [ ] `transform.map` support
49
- - [ ] Improved error handling
50
48
  - [ ] XML/HTML response handling
51
49
  - [ ] OpenAPI generation from tests
50
+ - [x] Support for running individual specs
51
+ - [x] Improved error handling
52
52
 
53
53
  Have a feature request? Open an issue on GitHub!
54
54
 
55
+ ## Looking for a Software Engineer?
56
+
57
+ I'm currently looking for opportunities where I can tackle meaningful problems and help build reliable software while mentoring the next generation of developers. If you're looking for a senior engineer with full-stack Rails expertise and a passion for clean, maintainable code, let's talk!
58
+
59
+ [bryan@itsthedevman.com](mailto:bryan@itsthedevman.com)
60
+
55
61
  ## Table of Contents
56
62
 
57
- - [Features](#features)
58
63
  - [Compatibility](#compatibility)
59
64
  - [Installation](#installation)
60
65
  - [Getting Started](#getting-started)
61
- - [Writing Your First Test](#writing-your-first-test)
66
+ - [Forging Your First Test](#forging-your-first-test)
67
+ - [Running Tests](#running-tests)
68
+ - [Targeting Specific Files](#targeting-specific-files)
69
+ - [Targeting Specific Specs](#targeting-specific-specs)
70
+ - [Targeting Individual Expectations](#targeting-individual-expectations)
62
71
  - [Configuration](#configuration)
63
72
  - [Basic Configuration](#basic-configuration)
64
73
  - [Framework Integration](#framework-integration)
@@ -88,12 +97,14 @@ Have a feature request? Open an issue on GitHub!
88
97
  - [How Tests Work](#how-tests-work)
89
98
  - [Contributing](#contributing)
90
99
  - [License](#license)
91
- - [Looking for a Software Engineer?](#looking-for-a-software-engineer)
100
+ - [Credits](#credits)
101
+
102
+ Also see: [API Documentation](https://itsthedevman.com/docs/spec_forge)
92
103
 
93
104
  ## Compatibility
94
105
 
95
106
  Currently tested on:
96
- - MRI Ruby 3.0+
107
+ - MRI Ruby 3.2+
97
108
  - NixOS (see `flake.nix` for details)
98
109
 
99
110
  ## Installation
@@ -131,7 +142,7 @@ bundle exec spec_forge init
131
142
 
132
143
  This creates the `spec_forge` directory containing factory definitions, test specifications, and global configuration.
133
144
 
134
- ## Writing Your First Test
145
+ ## Forging Your First Test
135
146
 
136
147
  Let's write a simple test to verify a user endpoint. Create a new spec file:
137
148
 
@@ -160,6 +171,63 @@ Run your tests with:
160
171
  spec_forge run
161
172
  ```
162
173
 
174
+ Since `run` is the default command, you can just use:
175
+
176
+ ```bash
177
+ spec_forge
178
+ ```
179
+
180
+ ## Running Tests
181
+
182
+ As your test suite grows, you'll want more control over which tests to run.
183
+
184
+ #### Targeting Specific Files
185
+
186
+ When working on a specific feature, run tests from a single file:
187
+
188
+ ```bash
189
+ spec_forge users # Runs all tests in specs/users.yml
190
+ ```
191
+
192
+ #### Targeting Specific Specs
193
+
194
+ Focus on a specific endpoint by running a single spec:
195
+
196
+ ```bash
197
+ spec_forge users:destroy_user # Runs all expectations in the destroy_user spec
198
+ ```
199
+
200
+ #### Targeting Individual Expectations
201
+
202
+ You can also run individual expectations within a spec. The format depends on whether the expectation has a name:
203
+
204
+ ```yaml
205
+ # specs/users.yml
206
+ destroy_user:
207
+ path: /users/:id
208
+ method: delete
209
+ expectations:
210
+ - expect: # Unnamed expectation
211
+ status: 200
212
+ - name: "Destroys a User" # Named expectation
213
+ expect:
214
+ status: 200
215
+ ```
216
+
217
+ For named expectations:
218
+ ```bash
219
+ # Format: <file>:<spec>:'<verb> <path> - <name>'
220
+ spec_forge users:destroy_user:'DELETE /users/:id - Destroys a User'
221
+ ```
222
+
223
+ For unnamed expectations:
224
+ ```bash
225
+ # Format: <file>:<spec>:'<verb> <path>'
226
+ spec_forge users:destroy_user:'DELETE /users/:id'
227
+ ```
228
+
229
+ **Note**: When targeting an unnamed expectation, SpecForge executes all matching expectations within that spec. This means if you have multiple unnamed expectations with the same verb and path, they will all run.
230
+
163
231
  ## Configuration
164
232
 
165
233
  ### Basic Configuration
@@ -393,13 +461,13 @@ list_posts:
393
461
  expectations:
394
462
  - expect:
395
463
  status: 200
396
- json:
397
- posts:
398
- matcher.include:
399
- - author:
400
- id: variables.author.id
401
- name: variables.author.name
402
- category: variables.category_name
464
+ json:
465
+ posts:
466
+ matcher.include:
467
+ - author:
468
+ id: variables.author.id
469
+ name: variables.author.name
470
+ category: variables.category_name
403
471
  ```
404
472
 
405
473
  ### Transformations
@@ -644,6 +712,10 @@ Please note that this project is released with a [Contributor Code of Conduct](C
644
712
 
645
713
  The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
646
714
 
647
- ## Looking for a Software Engineer?
715
+ ## Changelog
716
+
717
+ See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
718
+
719
+ ## Credits
648
720
 
649
- I'm looking for work! Please send serious enquiries to bryan@itsthedevman.com
721
+ - Author: Bryan "itsthedevman"
@@ -3,8 +3,6 @@
3
3
  module SpecForge
4
4
  class Attribute
5
5
  class Literal < Attribute
6
- REGEX_REGEX = /^\/.+\/[mnix\s]*$/i
7
-
8
6
  attr_reader :value
9
7
 
10
8
  #
@@ -14,18 +12,10 @@ module SpecForge
14
12
  def initialize(input)
15
13
  super
16
14
 
17
- @value =
18
- case input
19
- when REGEX_REGEX
20
- Regexp.new(input)
21
- else
22
- input
23
- end
15
+ @value = input
24
16
  end
25
17
 
26
- def resolve
27
- @value
28
- end
18
+ alias_method :resolve, :value
29
19
  end
30
20
  end
31
21
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Regex < Attribute
6
+ KEYWORD_REGEX = /^\/(?<content>[\s\S]+)\/(?<flags>[mnix\s]*)$/i
7
+
8
+ attr_reader :value
9
+
10
+ def initialize(input)
11
+ super
12
+
13
+ @value = parse_regex(input)
14
+ end
15
+
16
+ def resolve
17
+ @value
18
+ end
19
+
20
+ private
21
+
22
+ def parse_regex(input)
23
+ match = input.match(KEYWORD_REGEX)
24
+ captures = match.named_captures.symbolize_keys
25
+
26
+ flags = parse_flags(captures[:flags])
27
+ Regexp.new(captures[:content], flags)
28
+ end
29
+
30
+ # I would've used Regexp.new(string, string), but it raises when "n" is provided as a flag
31
+ def parse_flags(flags)
32
+ return 0 if flags.blank?
33
+
34
+ flags.strip.chars.reduce(0) do |options, flag|
35
+ case flag
36
+ when "i"
37
+ options | Regexp::IGNORECASE
38
+ when "m"
39
+ options | Regexp::MULTILINE
40
+ when "x"
41
+ options | Regexp::EXTENDED
42
+ when "n"
43
+ options | Regexp::NOENCODING
44
+ else
45
+ raise ArgumentError, "unknown regexp option: #{flag}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -10,6 +10,7 @@ require_relative "attribute/factory"
10
10
  require_relative "attribute/faker"
11
11
  require_relative "attribute/literal"
12
12
  require_relative "attribute/matcher"
13
+ require_relative "attribute/regex"
13
14
  require_relative "attribute/resolvable_array"
14
15
  require_relative "attribute/resolvable_hash"
15
16
  require_relative "attribute/transform"
@@ -81,6 +82,8 @@ module SpecForge
81
82
  Matcher.new(string)
82
83
  when Factory::KEYWORD_REGEX
83
84
  Factory.new(string)
85
+ when Regex::KEYWORD_REGEX
86
+ Regex.new(string)
84
87
  else
85
88
  Literal.new(string)
86
89
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Used internally by RSpec
6
+ # This class handles formatting backtraces, hence the name ;)
7
+ #
8
+ module BacktraceFormatter
9
+ def self.formatter
10
+ @formatter ||= RSpec::Core::BacktraceFormatter.new
11
+ end
12
+
13
+ def self.backtrace_line(line)
14
+ formatter.backtrace_line(line)
15
+ end
16
+
17
+ def self.format_backtrace(backtrace, example_metadata)
18
+ backtrace = SpecForge.backtrace_cleaner.clean(backtrace)
19
+
20
+ location = example_metadata[:example_group][:location]
21
+ line_number = example_metadata[:example_group][:line_number]
22
+
23
+ # Add the yaml location to the front so it's the first thing people see
24
+ ["#{location}:#{line_number}"] + backtrace
25
+ end
26
+ end
27
+ end
@@ -38,123 +38,33 @@ module SpecForge
38
38
  private
39
39
 
40
40
  def create_new_spec(name)
41
- actions.create_file(
41
+ actions.template(
42
+ "new_spec.tt",
42
43
  SpecForge.forge.join("specs", "#{name}.yml"),
43
- generate_spec(name)
44
+ context: Proxy.new(name).call
44
45
  )
45
46
  end
46
47
 
47
48
  def create_new_factory(name)
48
- actions.create_file(
49
+ actions.template(
50
+ "new_factory.tt",
49
51
  SpecForge.forge.join("factories", "#{name}.yml"),
50
- generate_factory(name)
52
+ context: Proxy.new(name).call
51
53
  )
52
54
  end
53
55
 
54
- def generate_spec(name)
55
- plural_name = name.pluralize
56
- singular_name = name.singularize
56
+ class Proxy
57
+ attr_reader :original_name, :singular_name, :plural_name
57
58
 
58
- base_spec = {url: ""}
59
- base_constraint = {expect: {status: 200}}
60
-
61
- hash = {
62
- ##################################################
63
- "index_#{plural_name}" => base_spec.merge(
64
- url: "/#{plural_name}",
65
- expectations: [base_constraint]
66
- ),
67
- ##################################################
68
- "show_#{singular_name}" => base_spec.merge(
69
- url: "/#{plural_name}/{id}",
70
- expectations: [
71
- base_constraint.merge(expect: {status: 404}),
72
- base_constraint.deep_merge(
73
- query: {id: 1},
74
- expect: {
75
- json: {
76
- name: "kind_of.string",
77
- email: /\w+@example\.com/i
78
- }
79
- }
80
- )
81
- ]
82
- ),
83
- ##################################################
84
- "create_#{singular_name}" => base_spec.merge(
85
- url: "/#{plural_name}",
86
- method: "post",
87
- expectations: [
88
- base_constraint.merge(expect: {status: 400}),
89
- base_constraint.deep_merge(
90
- variables: {
91
- name: "faker.name.name",
92
- role: "user"
93
- },
94
- body: {name: "variables.name"},
95
- expect: {
96
- json: {name: "variables.name", role: "variables.role"}
97
- }
98
- )
99
- ]
100
- ),
101
- ##################################################
102
- "update_#{singular_name}" => base_spec.merge(
103
- url: "/#{plural_name}/{id}",
104
- method: "patch",
105
- query: {id: 1},
106
- variables: {
107
- number: {
108
- "faker.number.between" => {from: 100_000, to: 999_999}
109
- }
110
- },
111
- expectations: [
112
- base_constraint.deep_merge(
113
- body: {number: "variables.number"},
114
- expect: {
115
- json: {name: "kind_of.string", number: "kind_of.integer"}
116
- }
117
- )
118
- ]
119
- ),
120
- ##################################################
121
- "destroy_#{singular_name}" => base_spec.merge(
122
- url: "/#{plural_name}/{id}",
123
- method: "delete",
124
- query: {id: 1},
125
- expectations: [
126
- base_constraint
127
- ]
128
- )
129
- }
130
-
131
- generate_yaml(hash)
132
- end
133
-
134
- def generate_factory(name)
135
- singular_name = name.singularize
136
-
137
- hash = {
138
- singular_name => {
139
- class: singular_name.titleize,
140
- attributes: {
141
- attribute: "value"
142
- }
143
- }
144
- }
145
-
146
- generate_yaml(hash)
147
- end
148
-
149
- def generate_yaml(hash)
150
- result = hash.deep_stringify_keys.join_map("\n") do |key, value|
151
- {key => value}.to_yaml
152
- .sub!("---\n", "")
153
- .gsub("!ruby/regexp ", "")
59
+ def initialize(name)
60
+ @original_name = name
61
+ @plural_name = name.pluralize
62
+ @singular_name = name.singularize
154
63
  end
155
64
 
156
- result.delete!("\"")
157
- result
65
+ def call
66
+ binding
67
+ end
158
68
  end
159
69
  end
160
70
  end
@@ -4,13 +4,63 @@ module SpecForge
4
4
  class CLI
5
5
  class Run < Command
6
6
  command_name "run"
7
- syntax "run"
8
- summary "Runs all specs"
7
+ syntax "run [target]"
8
+
9
+ summary "Runs specs loaded from spec_forge/specs/"
10
+ description "Runs specs loaded from spec_forge/specs/. The optional target argument allows running specific files, specs, or expectations."
11
+
12
+ example "spec_forge run",
13
+ "Run all specs in spec_forge/specs/"
14
+
15
+ example "spec_forge run users",
16
+ "Run all specs in users.yml"
17
+
18
+ example "spec_forge run users:create_user",
19
+ "Run all expectations in the create_user spec"
20
+
21
+ example "spec_forge run users:create_user:\"POST /users\"",
22
+ "Run expectations matching POST /users"
23
+
24
+ example "spec_forge run users:create_user:\"POST /users - Create Admin\"",
25
+ "Run the specific expectation named \"Create Admin\""
9
26
 
10
27
  # option "-n", "--no-docs", "Do not generate OpenAPI documentation on completion"
11
28
 
12
29
  def call
13
- SpecForge.run
30
+ return SpecForge.run if arguments.blank?
31
+
32
+ # spec_forge users:show_user
33
+ filter = extract_filter(arguments.first)
34
+
35
+ # Filter and run the specs
36
+ SpecForge.run(**filter)
37
+ end
38
+
39
+ private
40
+
41
+ #
42
+ # The input can be
43
+ #
44
+ # "<file_name>" for a file
45
+ # Example: "users"
46
+ #
47
+ # "<file_name>:<spec_name>" for a single spec
48
+ # Example: "users:show_user"
49
+ #
50
+ # "<file_name:<spec_name>:'<verb> <path> - <?name>'" for a single expectation
51
+ # Example:
52
+ # "users:show_user:'GET /users/:id'"
53
+ # Example with name:
54
+ # "users:show_user:'GET /users/:id - Returns 404 due to missing user'"
55
+ #
56
+ def extract_filter(input)
57
+ # Note: Only split 3 because the expectation name can have colons in them.
58
+ file_name, spec_name, expectation_name = input.split(":", 3).map(&:strip)
59
+
60
+ # Remove the quotes
61
+ expectation_name.gsub!(/^['"]|['"]$/, "") if expectation_name.present?
62
+
63
+ {file_name:, spec_name:, expectation_name:}
14
64
  end
15
65
  end
16
66
  end
@@ -30,6 +30,9 @@ module SpecForge
30
30
  def initialize
31
31
  config = Normalizer.default_configuration
32
32
 
33
+ # Allows me to modify the error backtrace reporting within rspec
34
+ RSpec.configuration.instance_variable_set(:@backtrace_formatter, BacktraceFormatter)
35
+
33
36
  config[:base_url] = "http://localhost:3000"
34
37
  config[:factories] = Factories.new
35
38
  config[:specs] = RSpec.configuration
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Core
5
+ module Notifications
6
+ #
7
+ # I did attempt to do this without monkey patching
8
+ # Getting around the `rspec` word was making it difficult
9
+ #
10
+ class SummaryNotification
11
+ # Customizes RSpec's failure output to:
12
+ # 1. Use 'spec_forge' instead of 'rspec' for rerun commands
13
+ # 2. Remove line numbers since SpecForge uses dynamic spec generation
14
+ alias_method :og_colorized_rerun_commands, :colorized_rerun_commands
15
+
16
+ def colorized_rerun_commands(colorizer)
17
+ # Updating these at this point fixes the re-run for some failures - it depends
18
+ failed_examples.each do |example|
19
+ metadata = example.metadata[:example_group]
20
+
21
+ # I might've uncovered an inconsistency here
22
+ # When multiple specs fail, it appears that the rerun_commands will use
23
+ # :rerun_file_path from the example's metadata.
24
+ # But when a single spec is ran and fails, it's using :location.
25
+ example.metadata[:location] = metadata[:rerun_file_path]
26
+ example.metadata[:line_number] = metadata[:line_number]
27
+ end
28
+
29
+ og_colorized_rerun_commands.gsub(/rspec/i, "spec_forge")
30
+ .gsub(/\[[\d:]+\]/, "")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.expand_path("core_ext/*.rb", __dir__)].sort.each do |path|
4
+ require path
5
+ end
@@ -5,32 +5,30 @@ module SpecForge
5
5
  #
6
6
  # Loads the factories from their yml files and registers them with FactoryBot
7
7
  #
8
- # @param path [String, Path] The base path where the factories directory are located
9
- #
10
- def self.load_and_register(base_path)
8
+ def self.load_and_register
11
9
  if SpecForge.configuration.factories.paths?
12
10
  FactoryBot.definition_file_paths = SpecForge.configuration.factories.paths
13
11
  end
14
12
 
15
13
  FactoryBot.find_definitions if SpecForge.configuration.factories.auto_discover?
16
14
 
17
- factories = load_from_path(base_path.join("factories", "**/*.yml"))
15
+ factories = load_from_files
18
16
  factories.each(&:register)
19
17
  end
20
18
 
21
19
  #
22
- # Loads any factories defined in the path. A single file can contain one or more factories
23
- #
24
- # @param path [String, Path] The path where the factories are located
20
+ # Loads any factories defined in the factories. A single file can contain one or more factories
25
21
  #
26
22
  # @return [Array<Factory>] An array of factories that were loaded.
27
23
  # Note: This factories have not been registered with FactoryBot.
28
24
  # See #register
29
25
  #
30
- def self.load_from_path(path)
26
+ def self.load_from_files
27
+ path = SpecForge.forge.join("factories", "**/*.yml")
28
+
31
29
  factories = []
32
30
 
33
- Dir[path].map do |file_path|
31
+ Dir[path].each do |file_path|
34
32
  hash = YAML.load_file(file_path).deep_symbolize_keys
35
33
 
36
34
  hash.each do |factory_name, factory_hash|
@@ -23,6 +23,9 @@ module SpecForge
23
23
  spec_forge.expectations.each do |expectation|
24
24
  # Define the example group
25
25
  describe(expectation.name) do
26
+ # Set up the class metadata for error reporting
27
+ runner_forge.set_group_metadata(self, spec_forge, expectation)
28
+
26
29
  constraints = expectation.constraints
27
30
 
28
31
  let!(:expected_status) { constraints.status.resolve }
@@ -31,6 +34,9 @@ module SpecForge
31
34
  before do
32
35
  # Ensure all variables are called and resolved, in case they are not referenced
33
36
  expectation.variables.resolve
37
+
38
+ # Set up the example metadata for error reporting
39
+ runner_forge.set_example_metadata(spec_forge, expectation)
34
40
  end
35
41
 
36
42
  subject(:response) { expectation.http_client.call }
@@ -54,9 +60,31 @@ module SpecForge
54
60
  end
55
61
  end
56
62
 
63
+ # @private
57
64
  def handle_debug(...)
58
65
  DebugProxy.new(...).call
59
66
  end
67
+
68
+ # @private
69
+ def set_group_metadata(context, spec, expectation)
70
+ metadata = {
71
+ file_path: spec.file_path,
72
+ absolute_file_path: spec.file_path,
73
+ line_number: spec.line_number,
74
+ location: spec.file_path,
75
+ rerun_file_path: "#{spec.file_name}:#{spec.name}:\"#{expectation.name}\""
76
+ }
77
+
78
+ context.metadata.merge!(metadata)
79
+ end
80
+
81
+ # @private
82
+ def set_example_metadata(spec, expectation)
83
+ # This is needed when an error raises in an example
84
+ metadata = {location: "#{spec.file_path}:#{spec.line_number}"}
85
+
86
+ RSpec.current_example.metadata.merge!(metadata)
87
+ end
60
88
  end
61
89
 
62
90
  ################################################################################################
@@ -26,8 +26,11 @@ module SpecForge
26
26
  def normalize_hash(hash)
27
27
  hash =
28
28
  hash.transform_values do |attribute|
29
- if attribute.is_a?(Attribute::Literal)
30
- normalize_literal(attribute.value)
29
+ case attribute
30
+ when Attribute::Regex
31
+ Attribute.from("matcher.match" => attribute.resolve)
32
+ when Attribute::Literal
33
+ Attribute.from("matcher.eq" => attribute.resolve)
31
34
  else
32
35
  attribute
33
36
  end
@@ -35,14 +38,6 @@ module SpecForge
35
38
 
36
39
  Attribute.from(hash)
37
40
  end
38
-
39
- def normalize_literal(value)
40
- if value.is_a?(Regexp)
41
- Attribute.from("matcher.match" => value)
42
- else
43
- Attribute.from("matcher.eq" => value)
44
- end
45
- end
46
41
  end
47
42
  end
48
43
  end
@@ -5,31 +5,49 @@ require_relative "spec/expectation"
5
5
  module SpecForge
6
6
  class Spec
7
7
  #
8
- # Loads the specs from their yml files and defines them with the test runner
8
+ # Loads and defines specs with the runner. Specs can be filtered using the optional parameters
9
9
  #
10
- # @param path [String, Path] The base path where the specs directory is located
10
+ # @param file_name [String, nil] The name of the file without the extension.
11
+ # @param spec_name [String, nil] The name of the spec in a yaml file
12
+ # @param expectation_name [String, nil] The name of the expectation for a spec.
11
13
  #
12
- def self.load_and_define(base_path)
13
- specs = load_from_path(base_path.join("specs", "**/*.yml"))
14
+ # @return [Array<Spec>]
15
+ #
16
+ def self.load_and_define(file_name: nil, spec_name: nil, expectation_name: nil)
17
+ specs = load_from_files
18
+
19
+ filter_specs(specs, file_name:, spec_name:, expectation_name:)
20
+
21
+ # Announce if we're using a filter
22
+ if file_name
23
+ filter = {file_name:, spec_name:, expectation_name:}.delete_if { |k, v| v.blank? }
24
+ filter.stringify_keys!
25
+ puts "Using filter: #{filter}"
26
+ end
27
+
14
28
  specs.each(&:define)
15
29
  end
16
30
 
17
31
  #
18
- # Loads any specs defined in the path. A single file can contain one or more specs
19
- #
20
- # @param path [String, Path] The path where the specs are located
32
+ # Loads any specs defined in the spec files. A single file can contain one or more specs
21
33
  #
22
34
  # @return [Array<Spec>] An array of specs that were loaded.
23
35
  #
24
- def self.load_from_path(path)
36
+ def self.load_from_files
37
+ path = SpecForge.forge.join("specs")
25
38
  specs = []
26
39
 
27
- Dir[path].map do |file_path|
28
- hash = YAML.load_file(file_path).deep_symbolize_keys
40
+ Dir[path.join("**/*.yml")].each do |file_path|
41
+ content = File.read(file_path)
42
+ hash = YAML.load(content).deep_symbolize_keys
29
43
 
30
44
  hash.each do |spec_name, spec_hash|
31
- spec_hash[:name] = spec_name
45
+ line_number = content.lines.index { |line| line.start_with?("#{spec_name}:") }
46
+
47
+ spec_hash[:name] = spec_name.to_s
32
48
  spec_hash[:file_path] = file_path
49
+ spec_hash[:file_name] = file_path.delete_prefix("#{path}/").delete_suffix(".yml")
50
+ spec_hash[:line_number] = line_number ? line_number + 1 : -1
33
51
 
34
52
  specs << new(**spec_hash)
35
53
  end
@@ -38,11 +56,34 @@ module SpecForge
38
56
  specs
39
57
  end
40
58
 
59
+ # @private
60
+ def self.filter_specs(specs, file_name: nil, spec_name: nil, expectation_name: nil)
61
+ # Guard against invalid partial filters
62
+ if expectation_name && spec_name.blank?
63
+ raise ArgumentError, "The spec's name is required when filtering by an expectation's name"
64
+ end
65
+
66
+ if spec_name && file_name.blank?
67
+ raise ArgumentError, "The spec's filename is required when filtering by a spec's name"
68
+ end
69
+
70
+ specs.select! { |spec| spec.file_name == file_name } if file_name
71
+ specs.select! { |spec| spec.name == spec_name } if spec_name
72
+
73
+ if expectation_name
74
+ specs.each do |spec|
75
+ spec.expectations.select! { |expectation| expectation.name == expectation_name }
76
+ end
77
+ end
78
+
79
+ specs
80
+ end
81
+
41
82
  ############################################################################
42
83
 
43
84
  attr_predicate :debug
44
85
 
45
- attr_reader :name, :file_path, :expectations
86
+ attr_reader :name, :file_path, :file_name, :line_number, :expectations
46
87
 
47
88
  #
48
89
  # Creates a Spec based on the input
@@ -52,9 +93,11 @@ module SpecForge
52
93
  # @param **input [Hash] Any attributes related to the spec, including expectations
53
94
  # See Normalizer::Spec
54
95
  #
55
- def initialize(name:, file_path:, **input)
96
+ def initialize(name:, file_path:, file_name:, line_number:, **input)
56
97
  @name = name
57
98
  @file_path = file_path
99
+ @file_name = file_name
100
+ @line_number = line_number
58
101
 
59
102
  input = Normalizer.normalize_spec!(input)
60
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SpecForge
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/spec_forge.rb CHANGED
@@ -17,8 +17,10 @@ require "thor"
17
17
  require "yaml"
18
18
 
19
19
  require_relative "spec_forge/attribute"
20
+ require_relative "spec_forge/backtrace_formatter"
20
21
  require_relative "spec_forge/cli"
21
22
  require_relative "spec_forge/configuration"
23
+ require_relative "spec_forge/core_ext"
22
24
  require_relative "spec_forge/error"
23
25
  require_relative "spec_forge/factory"
24
26
  require_relative "spec_forge/http"
@@ -34,15 +36,21 @@ module SpecForge
34
36
  #
35
37
  # @param path [String] The file path that contains factories and specs
36
38
  #
37
- def self.run(path = SpecForge.forge)
39
+ def self.run(file_name: nil, spec_name: nil, expectation_name: nil)
40
+ path = SpecForge.forge
41
+
42
+ # Initialize
38
43
  forge_helper = path.join("forge_helper.rb")
39
44
  require_relative forge_helper if File.exist?(forge_helper)
40
45
 
46
+ # Validate
41
47
  configuration.validate
42
48
 
43
- Factory.load_and_register(path)
44
- Spec.load_and_define(path)
49
+ # Prepare
50
+ Factory.load_and_register
51
+ Spec.load_and_define(file_name:, spec_name:, expectation_name:)
45
52
 
53
+ # Run
46
54
  Runner.run
47
55
  end
48
56
 
@@ -0,0 +1,4 @@
1
+ <%= original_name %>:
2
+ class: <%= singular_name.titleize %>
3
+ attributes:
4
+ active: true
@@ -0,0 +1,43 @@
1
+ index_<%= plural_name %>:
2
+ url: /<%= plural_name %>
3
+ expectations:
4
+ - expect:
5
+ status: 200
6
+
7
+ show_<%= singular_name %>:
8
+ url: /<%= plural_name %>/{id}
9
+ query:
10
+ id: 1
11
+ expectations:
12
+ - expect:
13
+ status: 200
14
+
15
+ create_<%= singular_name %>:
16
+ url: /<%= plural_name %>
17
+ method: post
18
+ body:
19
+ name: faker.name.name
20
+ email: faker.internet.email
21
+ expectations:
22
+ - expect:
23
+ status: 200
24
+
25
+ update_<%= singular_name %>:
26
+ url: /<%= plural_name %>/{id}
27
+ method: patch
28
+ query:
29
+ id: 1
30
+ body:
31
+ name:
32
+ expectations:
33
+ - expect:
34
+ status: 200
35
+
36
+ destroy_<%= singular_name %>:
37
+ url: /<%= plural_name %>/{id}
38
+ method: delete
39
+ query:
40
+ id: 1
41
+ expectations:
42
+ - expect:
43
+ status: 200
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spec_forge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bryan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-02-17 00:00:00.000000000 Z
11
+ date: 2025-02-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '7.0'
19
+ version: '7.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '7.0'
26
+ version: '7.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: commander
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0.1'
47
+ version: '0.2'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0.1'
54
+ version: '0.2'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: factory_bot
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -166,11 +166,13 @@ files:
166
166
  - lib/spec_forge/attribute/literal.rb
167
167
  - lib/spec_forge/attribute/matcher.rb
168
168
  - lib/spec_forge/attribute/parameterized.rb
169
+ - lib/spec_forge/attribute/regex.rb
169
170
  - lib/spec_forge/attribute/resolvable.rb
170
171
  - lib/spec_forge/attribute/resolvable_array.rb
171
172
  - lib/spec_forge/attribute/resolvable_hash.rb
172
173
  - lib/spec_forge/attribute/transform.rb
173
174
  - lib/spec_forge/attribute/variable.rb
175
+ - lib/spec_forge/backtrace_formatter.rb
174
176
  - lib/spec_forge/cli.rb
175
177
  - lib/spec_forge/cli/actions.rb
176
178
  - lib/spec_forge/cli/command.rb
@@ -178,6 +180,8 @@ files:
178
180
  - lib/spec_forge/cli/new.rb
179
181
  - lib/spec_forge/cli/run.rb
180
182
  - lib/spec_forge/configuration.rb
183
+ - lib/spec_forge/core_ext.rb
184
+ - lib/spec_forge/core_ext/rspec.rb
181
185
  - lib/spec_forge/error.rb
182
186
  - lib/spec_forge/factory.rb
183
187
  - lib/spec_forge/http.rb
@@ -199,6 +203,8 @@ files:
199
203
  - lib/spec_forge/type.rb
200
204
  - lib/spec_forge/version.rb
201
205
  - lib/templates/forge_helper.tt
206
+ - lib/templates/new_factory.tt
207
+ - lib/templates/new_spec.tt
202
208
  - spec_forge/factories/user.yml
203
209
  - spec_forge/forge_helper.rb
204
210
  - spec_forge/specs/users.yml