spec_forge 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.rspec +1 -0
  4. data/.standard.yml +3 -0
  5. data/CHANGELOG.md +5 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +426 -0
  9. data/Rakefile +6 -0
  10. data/bin/spec_forge +5 -0
  11. data/flake.lock +61 -0
  12. data/flake.nix +41 -0
  13. data/lib/spec_forge/attribute/chainable.rb +86 -0
  14. data/lib/spec_forge/attribute/factory.rb +63 -0
  15. data/lib/spec_forge/attribute/faker.rb +54 -0
  16. data/lib/spec_forge/attribute/literal.rb +27 -0
  17. data/lib/spec_forge/attribute/matcher.rb +118 -0
  18. data/lib/spec_forge/attribute/parameterized.rb +76 -0
  19. data/lib/spec_forge/attribute/resolvable.rb +21 -0
  20. data/lib/spec_forge/attribute/resolvable_array.rb +24 -0
  21. data/lib/spec_forge/attribute/resolvable_hash.rb +24 -0
  22. data/lib/spec_forge/attribute/transform.rb +39 -0
  23. data/lib/spec_forge/attribute/variable.rb +36 -0
  24. data/lib/spec_forge/attribute.rb +208 -0
  25. data/lib/spec_forge/cli/actions.rb +23 -0
  26. data/lib/spec_forge/cli/command.rb +127 -0
  27. data/lib/spec_forge/cli/init.rb +29 -0
  28. data/lib/spec_forge/cli/new.rb +161 -0
  29. data/lib/spec_forge/cli/run.rb +17 -0
  30. data/lib/spec_forge/cli.rb +43 -0
  31. data/lib/spec_forge/config.rb +84 -0
  32. data/lib/spec_forge/environment.rb +71 -0
  33. data/lib/spec_forge/error.rb +150 -0
  34. data/lib/spec_forge/factory.rb +104 -0
  35. data/lib/spec_forge/http/backend.rb +106 -0
  36. data/lib/spec_forge/http/client.rb +33 -0
  37. data/lib/spec_forge/http/request.rb +93 -0
  38. data/lib/spec_forge/http/verb.rb +118 -0
  39. data/lib/spec_forge/http.rb +6 -0
  40. data/lib/spec_forge/normalizer/config.rb +104 -0
  41. data/lib/spec_forge/normalizer/constraint.rb +47 -0
  42. data/lib/spec_forge/normalizer/expectation.rb +85 -0
  43. data/lib/spec_forge/normalizer/factory.rb +65 -0
  44. data/lib/spec_forge/normalizer/factory_reference.rb +66 -0
  45. data/lib/spec_forge/normalizer/spec.rb +73 -0
  46. data/lib/spec_forge/normalizer.rb +183 -0
  47. data/lib/spec_forge/runner.rb +91 -0
  48. data/lib/spec_forge/spec/expectation/constraint.rb +52 -0
  49. data/lib/spec_forge/spec/expectation.rb +53 -0
  50. data/lib/spec_forge/spec.rb +77 -0
  51. data/lib/spec_forge/type.rb +45 -0
  52. data/lib/spec_forge/version.rb +5 -0
  53. data/lib/spec_forge.rb +90 -0
  54. data/lib/templates/config.tt +19 -0
  55. data/spec_forge/config.yml +19 -0
  56. data/spec_forge/factories/user.yml +4 -0
  57. data/spec_forge/specs/users.yml +63 -0
  58. metadata +234 -0
data/flake.nix ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ description = "Ruby 3.3.6 development environment";
3
+
4
+ inputs = {
5
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6
+ flake-utils.url = "github:numtide/flake-utils";
7
+ };
8
+
9
+ outputs = { self, nixpkgs, flake-utils }:
10
+ flake-utils.lib.eachDefaultSystem (system:
11
+ let
12
+ pkgs = nixpkgs.legacyPackages.${system};
13
+ in
14
+ {
15
+ devShells.default = pkgs.mkShell {
16
+ buildInputs = with pkgs; [
17
+ (ruby_3_3.override {
18
+ jemallocSupport = true;
19
+ docSupport = false;
20
+ })
21
+
22
+ # Dependencies for native gems
23
+ pkg-config
24
+ openssl
25
+ readline
26
+ zstd
27
+ libyaml
28
+ ];
29
+
30
+ shellHook = ''
31
+ export GEM_HOME="$PWD/vendor/bundle"
32
+ export GEM_PATH="$GEM_HOME"
33
+ export PATH="$GEM_HOME/bin:$PATH"
34
+
35
+ echo "checking gems"
36
+ bundle check || bundle install
37
+ '';
38
+ };
39
+ }
40
+ );
41
+ }
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ module Chainable
6
+ NUMBER_REGEX = /^\d+$/i
7
+
8
+ attr_reader :invocation_chain, :base_object
9
+
10
+ #
11
+ # Represents any attribute that is a series of chained invocations:
12
+ #
13
+ # <keyword>.<header>.<segment(hash_key | method | index)>...
14
+ #
15
+ # This module is not used as is, but is included in another class.
16
+ # Note: There can be any n number of segments.
17
+ #
18
+ def initialize(...)
19
+ super
20
+
21
+ # Drop the keyword
22
+ sections = input.split(".")[1..]
23
+
24
+ # The "header" is the first element in this array
25
+ @invocation_chain = sections || []
26
+ end
27
+
28
+ def value
29
+ invoke_chain
30
+ end
31
+
32
+ #
33
+ # Custom implementation to ensure the underlying values are resolved
34
+ # without breaking #value's functionality
35
+ #
36
+ def resolve
37
+ @resolved ||= __resolve(invoke_chain(resolve: true))
38
+ end
39
+
40
+ private
41
+
42
+ def invoke_chain(resolve: false)
43
+ current_value = @base_object
44
+
45
+ invocation_chain.each do |step|
46
+ object = retrieve_value(current_value, resolve:)
47
+ current_value = invoke(step, object)
48
+ end
49
+
50
+ retrieve_value(current_value, resolve:)
51
+ end
52
+
53
+ def retrieve_value(object, resolve: false)
54
+ return object if !object.is_a?(Attribute)
55
+
56
+ resolve ? object.resolve : object.value
57
+ end
58
+
59
+ def invoke(step, object)
60
+ if hash_key?(object, step)
61
+ object[step.to_sym]
62
+ elsif index?(object, step)
63
+ object[step.to_i]
64
+ elsif method?(object, step)
65
+ object.public_send(step)
66
+ else
67
+ raise InvalidInvocationError.new(step, object)
68
+ end
69
+ end
70
+
71
+ def hash_key?(object, key)
72
+ # This is to support the silly delegator
73
+ method?(object, :key?) && object.key?(key.to_sym)
74
+ end
75
+
76
+ def method?(object, method_name)
77
+ object.respond_to?(method_name)
78
+ end
79
+
80
+ def index?(object, step)
81
+ # This is to support the silly delegator
82
+ method?(object, :index) && step.match?(NUMBER_REGEX)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Factory < Parameterized
6
+ include Chainable
7
+
8
+ KEYWORD_REGEX = /^factories\./i
9
+
10
+ BUILD_STRATEGIES = %w[
11
+ build
12
+ create
13
+ attributes_for
14
+ build_stubbed
15
+ ].freeze
16
+
17
+ attr_reader :factory_name
18
+
19
+ #
20
+ # Represents any attribute that is a factory reference
21
+ #
22
+ # factories.<factory_name>
23
+ #
24
+ def initialize(...)
25
+ super
26
+
27
+ @factory_name = invocation_chain.shift&.to_sym
28
+
29
+ # Check the arguments before preparing them
30
+ arguments[:keyword] = Normalizer.normalize_factory_reference!(arguments[:keyword])
31
+
32
+ prepare_arguments!
33
+ end
34
+
35
+ def value
36
+ @base_object = create_factory_object
37
+ super
38
+ end
39
+
40
+ def resolve
41
+ @base_object = create_factory_object
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ def create_factory_object
48
+ attributes = arguments[:keyword]
49
+ return FactoryBot.create(@factory_name) if attributes.blank?
50
+
51
+ # Determine build strat
52
+ build_strategy = attributes[:build_strategy].resolve
53
+
54
+ # stubbed => build_stubbed
55
+ build_strategy.prepend("build_") if build_strategy == "stubbed"
56
+ raise InvalidBuildStrategy, build_strategy unless BUILD_STRATEGIES.include?(build_strategy)
57
+
58
+ attributes = attributes[:attributes].resolve
59
+ FactoryBot.public_send(build_strategy, @factory_name, **attributes)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Faker < Parameterized
6
+ KEYWORD_REGEX = /^faker\./i
7
+
8
+ attr_reader :faker_class, :faker_method
9
+
10
+ #
11
+ # Represents any attribute that is a faker call
12
+ #
13
+ # faker.<faker_class>.<faker_method>
14
+ #
15
+ def initialize(...)
16
+ super
17
+
18
+ # As of right now, Faker only goes 2 sub classes deep. I've added +2 padding just in case
19
+ # faker.class.method
20
+ # faker.class.subclass.method
21
+ sections = input.split(".")[0..5]
22
+
23
+ class_name = sections[0..-2].join("::").underscore.classify
24
+ method_name = sections.last
25
+
26
+ # Load the class
27
+ @faker_class = begin
28
+ "::#{class_name}".constantize
29
+ rescue NameError
30
+ raise InvalidFakerClassError, class_name
31
+ end
32
+
33
+ # Load the method
34
+ @faker_method = begin
35
+ faker_class.method(method_name)
36
+ rescue NameError
37
+ raise InvalidFakerMethodError.new(method_name, faker_class)
38
+ end
39
+
40
+ prepare_arguments!
41
+ end
42
+
43
+ def value
44
+ if uses_positional_arguments?(faker_method)
45
+ faker_method.call(*arguments[:positional].resolve)
46
+ elsif uses_keyword_arguments?(faker_method)
47
+ faker_method.call(**arguments[:keyword].resolve)
48
+ else
49
+ faker_method.call
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Literal < Attribute
6
+ REGEX_REGEX = /^\/.+\/[mnix\s]*$/i
7
+
8
+ attr_reader :value
9
+
10
+ #
11
+ # Represents any attribute that is a literal value.
12
+ # A literal value can be any value YAML value, except Array and Hash
13
+ #
14
+ def initialize(input)
15
+ super
16
+
17
+ @value =
18
+ case input
19
+ when REGEX_REGEX
20
+ Regexp.new(input)
21
+ else
22
+ input
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Matcher < Parameterized
6
+ class Methods
7
+ include RSpec::Matchers
8
+ end
9
+
10
+ MATCHER_METHODS = Methods.new.freeze
11
+ KEYWORD_REGEX = /^matcher\.|^be\.|^kind_of\./i
12
+
13
+ LITERAL_MAPPINGS = {
14
+ "nil" => nil,
15
+ "true" => true,
16
+ "false" => false
17
+ }.freeze
18
+
19
+ attr_reader :matcher_method
20
+
21
+ #
22
+ # Represents any attribute that is a matcher call.
23
+ #
24
+ # matcher.<method>
25
+ # be.<method>
26
+ # kind_of.<method>
27
+ #
28
+ def initialize(...)
29
+ super
30
+
31
+ namespace, method = extract_namespace_and_method
32
+
33
+ @matcher_method =
34
+ case namespace
35
+ when "be"
36
+ resolve_be_matcher(method)
37
+ when "kind_of"
38
+ resolve_kind_of_matcher(method)
39
+ else
40
+ resolve_matcher(method)
41
+ end
42
+
43
+ prepare_arguments!
44
+ end
45
+
46
+ def value
47
+ if uses_positional_arguments?(matcher_method)
48
+ positional = arguments[:positional].resolve.each do |value|
49
+ value.deep_stringify_keys! if value.respond_to?(:deep_stringify_keys!)
50
+ end
51
+
52
+ matcher_method.call(*positional)
53
+ elsif uses_keyword_arguments?(matcher_method)
54
+ matcher_method.call(**arguments[:keyword].resolve.deep_stringify_keys)
55
+ else
56
+ matcher_method.call
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def extract_namespace_and_method
63
+ sections = input.split(".", 2)
64
+
65
+ if sections.size > 1
66
+ sections[..1]
67
+ else
68
+ [nil, sections.first]
69
+ end
70
+ end
71
+
72
+ def resolve_matcher(method_name, namespace: MATCHER_METHODS)
73
+ namespace.public_method(method_name)
74
+ end
75
+
76
+ def resolve_be_matcher(method)
77
+ # Resolve any custom matchers
78
+ resolved_matcher =
79
+ case method
80
+
81
+ # be.>(*args)
82
+ when "greater_than", "greater"
83
+ resolve_matcher(:>, namespace: MATCHER_METHODS.be)
84
+
85
+ # be.>=(*args)
86
+ when "greater_than_or_equal", "greater_or_equal"
87
+ resolve_matcher(:>=, namespace: MATCHER_METHODS.be)
88
+
89
+ # be.<(*args)
90
+ when "less_than", "less"
91
+ resolve_matcher(:<, namespace: MATCHER_METHODS.be)
92
+
93
+ # be.<=(*args)
94
+ when "less_than_or_equal", "less_or_equal"
95
+ resolve_matcher(:<=, namespace: MATCHER_METHODS.be)
96
+
97
+ # be(nil), be(true), be(false)
98
+ when "nil", "true", "false"
99
+ arguments[:positional].insert(0, LITERAL_MAPPINGS[method])
100
+ resolve_matcher(:be)
101
+ end
102
+
103
+ # Return the matcher if we found one
104
+ return resolved_matcher if resolved_matcher
105
+
106
+ # No matcher found, we're going to assume it's prefixed with "be_"
107
+ resolve_matcher(:"be_#{method}")
108
+ end
109
+
110
+ def resolve_kind_of_matcher(method)
111
+ type_class = Object.const_get(method.capitalize)
112
+ arguments[:positional].insert(0, type_class)
113
+
114
+ resolve_matcher(:be_kind_of)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Parameterized < Attribute
6
+ def self.from_hash(hash)
7
+ metadata = hash.first
8
+
9
+ input = metadata.first
10
+ arguments = metadata.second
11
+
12
+ case arguments
13
+ when ArrayLike
14
+ new(input, arguments)
15
+ when HashLike
16
+ # Offset for positional arguments. No support for both at this time
17
+ new(input, [], arguments)
18
+ else
19
+ # Single value
20
+ new(input, [arguments])
21
+ end
22
+ end
23
+
24
+ attr_reader :arguments
25
+
26
+ #
27
+ # Represents any attribute that is written in expanded form.
28
+ # Expanded form is just a fancy name for a hash.
29
+ #
30
+ # keyword:
31
+ # <attribute>:
32
+ # <keyword_arg>: <value>
33
+ #
34
+ # positional:
35
+ # <attribute>:
36
+ # - <positional_arg>
37
+ # - <positional_arg>
38
+ #
39
+ # @param input [Hash] The key that contains these arguments
40
+ # @param positional [Array] Any positional arguments
41
+ # @param keyword [Hash] Any keyword arguments
42
+ #
43
+ def initialize(input, positional = [], keyword = {})
44
+ super(input.to_s.downcase)
45
+
46
+ @arguments = {positional:, keyword:}
47
+ end
48
+
49
+ def bind_variables(variables)
50
+ arguments[:positional].each { |v| Attribute.bind_variables(v, variables) }
51
+ arguments[:keyword].each_value { |v| Attribute.bind_variables(v, variables) }
52
+ end
53
+
54
+ protected
55
+
56
+ #
57
+ # Converts the arguments into Attributes
58
+ #
59
+ # @note This needs to be called by the inheriting class.
60
+ # This is to allow inheriting classes to normalize their arguments before
61
+ # they are converted to Attributes
62
+ #
63
+ def prepare_arguments!
64
+ @arguments = Attribute.from(arguments)
65
+ end
66
+
67
+ def uses_positional_arguments?(method)
68
+ method.parameters.any? { |a| [:req, :opt, :rest].include?(a.first) }
69
+ end
70
+
71
+ def uses_keyword_arguments?(method)
72
+ method.parameters.any? { |a| [:keyreq, :key, :keyrest].include?(a.first) }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ #
6
+ # Helpers for ResolvableHash and ResolvableArray
7
+ #
8
+ module Resolvable
9
+ # @private
10
+ def to_proc
11
+ this = self
12
+ -> { this.resolve }
13
+ end
14
+
15
+ # @private
16
+ def resolvable_proc
17
+ ->(v) { v.respond_to?(:resolve) ? v.resolve : v }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ #
6
+ # Represents an Array that may contain Attributes
7
+ #
8
+ class ResolvableArray < SimpleDelegator
9
+ include Resolvable
10
+
11
+ def value
12
+ __getobj__
13
+ end
14
+
15
+ def resolve
16
+ value.map(&resolvable_proc)
17
+ end
18
+
19
+ def bind_variables(variables)
20
+ value.each { |v| Attribute.bind_variables(v, variables) }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ #
6
+ # Represents a hash that may contain Attributes
7
+ #
8
+ class ResolvableHash < SimpleDelegator
9
+ include Resolvable
10
+
11
+ def value
12
+ __getobj__
13
+ end
14
+
15
+ def resolve
16
+ value.transform_values(&resolvable_proc)
17
+ end
18
+
19
+ def bind_variables(variables)
20
+ value.each_value { |v| Attribute.bind_variables(v, variables) }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Transform < Parameterized
6
+ KEYWORD_REGEX = /^transform\./i
7
+
8
+ TRANSFORM_METHODS = %w[
9
+ join
10
+ ].freeze
11
+
12
+ attr_reader :function
13
+
14
+ #
15
+ # Represents any attribute that is a transform call
16
+ #
17
+ # transform.<function>
18
+ #
19
+ def initialize(...)
20
+ super
21
+
22
+ # Remove prefix
23
+ @function = @input.sub("transform.", "")
24
+
25
+ raise InvalidTransformFunctionError, input unless TRANSFORM_METHODS.include?(function)
26
+
27
+ prepare_arguments!
28
+ end
29
+
30
+ def value
31
+ case function
32
+ when "join"
33
+ # Technically supports any attribute, but I ain't gonna test all them edge cases
34
+ arguments[:positional].resolve.join
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ class Variable < Attribute
6
+ include Chainable
7
+
8
+ KEYWORD_REGEX = /^variables\./i
9
+
10
+ attr_reader :variable_name
11
+
12
+ #
13
+ # Represents any attribute that is a variable reference
14
+ #
15
+ # variables.<variable_name>
16
+ #
17
+ def initialize(...)
18
+ super
19
+
20
+ # Remove the variable name from the chain
21
+ @variable_name = invocation_chain.shift&.to_sym
22
+ end
23
+
24
+ def bind_variables(variables)
25
+ raise InvalidTypeError.new(variables, Hash, for: "'variables'") unless Type.hash?(variables)
26
+
27
+ # Don't nil check here.
28
+ raise MissingVariableError, variable_name unless variables.key?(variable_name)
29
+
30
+ @base_object = variables[variable_name]
31
+
32
+ self
33
+ end
34
+ end
35
+ end
36
+ end