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 +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +92 -20
- data/lib/spec_forge/attribute/literal.rb +2 -12
- data/lib/spec_forge/attribute/regex.rb +51 -0
- data/lib/spec_forge/attribute.rb +3 -0
- data/lib/spec_forge/backtrace_formatter.rb +27 -0
- data/lib/spec_forge/cli/new.rb +15 -105
- data/lib/spec_forge/cli/run.rb +53 -3
- data/lib/spec_forge/configuration.rb +3 -0
- data/lib/spec_forge/core_ext/rspec.rb +35 -0
- data/lib/spec_forge/core_ext.rb +5 -0
- data/lib/spec_forge/factory.rb +7 -9
- data/lib/spec_forge/runner.rb +28 -0
- data/lib/spec_forge/spec/expectation/constraint.rb +5 -10
- data/lib/spec_forge/spec.rb +56 -13
- data/lib/spec_forge/version.rb +1 -1
- data/lib/spec_forge.rb +11 -3
- data/lib/templates/new_factory.tt +4 -0
- data/lib/templates/new_spec.tt +43 -0
- metadata +12 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 658fd0830ce9179c1fe7ea880e2ed8196ccbc47bc8b244f6fc40c458ca900cca
|
4
|
+
data.tar.gz: 3658379f52f23ffa362ded1c50b28e052911b6602ac3a3bed02b1fd9c8ee334c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
[](https://badge.fury.io/rb/spec_forge)
|
4
|
+

|
4
5
|
[](https://github.com/itsthedevman/spec_forge/actions/workflows/main.yml)
|
5
|
-

|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
- [
|
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
|
-
- [
|
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.
|
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
|
-
##
|
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
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
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
|
-
##
|
715
|
+
## Changelog
|
716
|
+
|
717
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of changes.
|
718
|
+
|
719
|
+
## Credits
|
648
720
|
|
649
|
-
|
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
|
-
|
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
|
data/lib/spec_forge/attribute.rb
CHANGED
@@ -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
|
data/lib/spec_forge/cli/new.rb
CHANGED
@@ -38,123 +38,33 @@ module SpecForge
|
|
38
38
|
private
|
39
39
|
|
40
40
|
def create_new_spec(name)
|
41
|
-
actions.
|
41
|
+
actions.template(
|
42
|
+
"new_spec.tt",
|
42
43
|
SpecForge.forge.join("specs", "#{name}.yml"),
|
43
|
-
|
44
|
+
context: Proxy.new(name).call
|
44
45
|
)
|
45
46
|
end
|
46
47
|
|
47
48
|
def create_new_factory(name)
|
48
|
-
actions.
|
49
|
+
actions.template(
|
50
|
+
"new_factory.tt",
|
49
51
|
SpecForge.forge.join("factories", "#{name}.yml"),
|
50
|
-
|
52
|
+
context: Proxy.new(name).call
|
51
53
|
)
|
52
54
|
end
|
53
55
|
|
54
|
-
|
55
|
-
|
56
|
-
singular_name = name.singularize
|
56
|
+
class Proxy
|
57
|
+
attr_reader :original_name, :singular_name, :plural_name
|
57
58
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
157
|
-
|
65
|
+
def call
|
66
|
+
binding
|
67
|
+
end
|
158
68
|
end
|
159
69
|
end
|
160
70
|
end
|
data/lib/spec_forge/cli/run.rb
CHANGED
@@ -4,13 +4,63 @@ module SpecForge
|
|
4
4
|
class CLI
|
5
5
|
class Run < Command
|
6
6
|
command_name "run"
|
7
|
-
syntax "run"
|
8
|
-
|
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
|
data/lib/spec_forge/factory.rb
CHANGED
@@ -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
|
-
|
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 =
|
15
|
+
factories = load_from_files
|
18
16
|
factories.each(&:register)
|
19
17
|
end
|
20
18
|
|
21
19
|
#
|
22
|
-
# Loads any factories defined in the
|
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.
|
26
|
+
def self.load_from_files
|
27
|
+
path = SpecForge.forge.join("factories", "**/*.yml")
|
28
|
+
|
31
29
|
factories = []
|
32
30
|
|
33
|
-
Dir[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|
|
data/lib/spec_forge/runner.rb
CHANGED
@@ -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
|
-
|
30
|
-
|
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
|
data/lib/spec_forge/spec.rb
CHANGED
@@ -5,31 +5,49 @@ require_relative "spec/expectation"
|
|
5
5
|
module SpecForge
|
6
6
|
class Spec
|
7
7
|
#
|
8
|
-
# Loads
|
8
|
+
# Loads and defines specs with the runner. Specs can be filtered using the optional parameters
|
9
9
|
#
|
10
|
-
# @param
|
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
|
-
|
13
|
-
|
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
|
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.
|
36
|
+
def self.load_from_files
|
37
|
+
path = SpecForge.forge.join("specs")
|
25
38
|
specs = []
|
26
39
|
|
27
|
-
Dir[path].
|
28
|
-
|
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
|
-
|
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
|
|
data/lib/spec_forge/version.rb
CHANGED
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(
|
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
|
-
|
44
|
-
|
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,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.
|
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-
|
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.
|
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.
|
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.
|
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.
|
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
|