gherkin_system 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.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cucumber/tag_expressions"
4
+
5
+ module GherkinSystem
6
+ # One Before, After, or Around hook.
7
+ class Hook
8
+ # @return [Proc]
9
+ attr_reader :block
10
+
11
+ # @param expression [Object, nil] parsed tag expression, or nil to match every scenario
12
+ # @param block [Proc]
13
+ def initialize(expression, block)
14
+ @expression = expression
15
+ @block = block
16
+ end
17
+
18
+ # @param tags [Array<String>]
19
+ # @return [Boolean]
20
+ def applies_to?(tags)
21
+ @expression.nil? || @expression.evaluate(tags)
22
+ end
23
+ end
24
+
25
+ # Hooks registered by {Hooks}.
26
+ class HookRegistry
27
+ def initialize
28
+ @hooks = { before: [], after: [], around: [] }
29
+ end
30
+
31
+ # @param kind [Symbol] :before, :after, or :around
32
+ # @param tag_expression [String, nil]
33
+ # @param block [Proc]
34
+ # @return [Hook]
35
+ def add(kind, tag_expression, block)
36
+ hook = Hook.new(parse(tag_expression), block)
37
+ @hooks.fetch(kind) << hook
38
+ hook
39
+ end
40
+
41
+ # @param kind [Symbol]
42
+ # @param tags [Array<String>]
43
+ # @return [Array<Hook>]
44
+ def matching(kind, tags)
45
+ @hooks.fetch(kind).select { |hook| hook.applies_to?(tags) }
46
+ end
47
+
48
+ private
49
+
50
+ def parse(expression)
51
+ return nil if expression.nil? || expression.to_s.strip.empty?
52
+
53
+ Cucumber::TagExpressions::Parser.new.parse(expression)
54
+ rescue StandardError => e
55
+ raise CompilationError, "Malformed hook tag expression #{expression.inspect}: #{e.message}"
56
+ end
57
+ end
58
+
59
+ # DSL extended by hook modules.
60
+ #
61
+ # @example
62
+ # module CheckoutHooks
63
+ # extend GherkinSystem::Hooks
64
+ #
65
+ # Before("@admin") { visit admin_root_path }
66
+ # end
67
+ module Hooks
68
+ # @param tag_expression [String, nil]
69
+ # @return [Hook]
70
+ def Before(tag_expression = nil, &block)
71
+ GherkinSystem.hook_registry.add(:before, tag_expression, block)
72
+ end
73
+
74
+ # @param tag_expression [String, nil]
75
+ # @return [Hook]
76
+ def After(tag_expression = nil, &block)
77
+ GherkinSystem.hook_registry.add(:after, tag_expression, block)
78
+ end
79
+
80
+ # @param tag_expression [String, nil]
81
+ # @return [Hook]
82
+ def Around(tag_expression = nil, &block)
83
+ GherkinSystem.hook_registry.add(:around, tag_expression, block)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cucumber/cucumber_expressions/parameter_type"
4
+ require "cucumber/cucumber_expressions/parameter_type_registry"
5
+
6
+ module GherkinSystem
7
+ # Custom Cucumber Expressions parameter types.
8
+ class ParameterRegistry
9
+ # @return [Cucumber::CucumberExpressions::ParameterTypeRegistry]
10
+ attr_reader :registry
11
+
12
+ def initialize
13
+ @registry = Cucumber::CucumberExpressions::ParameterTypeRegistry.new
14
+ end
15
+
16
+ # @param name [String]
17
+ # @param regexp [Regexp, String]
18
+ # @param transformer [Proc]
19
+ # @param type [Class]
20
+ # @param prefer_for_regexp_match [Boolean]
21
+ # @return [void]
22
+ def define(name:, regexp:, transformer:, type: String, prefer_for_regexp_match: false)
23
+ parameter = Cucumber::CucumberExpressions::ParameterType.new(
24
+ name,
25
+ regexp,
26
+ type || String,
27
+ transformer,
28
+ true,
29
+ prefer_for_regexp_match
30
+ )
31
+ registry.define_parameter_type(parameter)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gherkin/pickles/compiler"
4
+ require "gherkin/parser"
5
+
6
+ module GherkinSystem
7
+ # Turns a feature file into {Scenario} objects. Cucumber messages do not leave this class.
8
+ class Parser
9
+ # @param path [String]
10
+ # @return [Array<Scenario>]
11
+ def self.parse_file(path)
12
+ parse(File.read(path), path)
13
+ end
14
+
15
+ # @param source_text [String]
16
+ # @param uri [String]
17
+ # @return [Array<Scenario>]
18
+ def self.parse(source_text, uri)
19
+ document = Gherkin::Parser.new.parse(source_text)
20
+ new(document, uri).scenarios
21
+ rescue Gherkin::ParserError => e
22
+ raise CompilationError, "#{uri}: #{e.message}"
23
+ end
24
+
25
+ def initialize(document, uri)
26
+ @document = document
27
+ @uri = uri.to_s
28
+ @step_lines = {}
29
+ @step_keywords = {}
30
+ @example_rows = {}
31
+ @scenario_lines = {}
32
+ end
33
+
34
+ # @return [Array<Scenario>]
35
+ def scenarios
36
+ return [] unless feature
37
+
38
+ index_children(feature.children)
39
+ pickles.map { |pickle| build_scenario(pickle) }
40
+ end
41
+
42
+ private
43
+
44
+ def feature
45
+ @document.feature
46
+ end
47
+
48
+ def pickles
49
+ source = Struct.new(:uri).new(@uri)
50
+ generator = Cucumber::Messages::Helpers::IdGenerator::Incrementing.new
51
+ Gherkin::Pickles::Compiler.new(generator).compile(@document, source)
52
+ end
53
+
54
+ def index_children(children)
55
+ children.each do |child|
56
+ index_children(child.rule.children) if child.rule
57
+ index_steps(child.background.steps) if child.background
58
+ index_scenario(child.scenario) if child.scenario
59
+ end
60
+ end
61
+
62
+ def index_scenario(scenario)
63
+ @scenario_lines[scenario.id] = scenario.location.line
64
+ index_steps(scenario.steps)
65
+ scenario.examples.each do |examples|
66
+ Array(examples.table_body).each do |row|
67
+ @example_rows[row.id] = row.cells.map(&:value)
68
+ end
69
+ end
70
+ end
71
+
72
+ def index_steps(steps)
73
+ steps.each do |step|
74
+ @step_lines[step.id] = step.location.line
75
+ @step_keywords[step.id] = step.keyword.to_s.strip
76
+ end
77
+ end
78
+
79
+ def build_scenario(pickle)
80
+ Scenario.new(
81
+ uri: @uri,
82
+ feature_name: feature.name,
83
+ name: pickle.name,
84
+ tags: pickle.tags.map(&:name),
85
+ example_values: example_values(pickle),
86
+ line: pickle.location.line,
87
+ scenario_line: scenario_line(pickle),
88
+ steps: pickle.steps.map { |step| build_step(step) }
89
+ )
90
+ end
91
+
92
+ def example_values(pickle)
93
+ pickle.ast_node_ids.filter_map { |id| @example_rows[id] }.first || []
94
+ end
95
+
96
+ def scenario_line(pickle)
97
+ pickle.ast_node_ids.filter_map { |id| @scenario_lines[id] }.first || pickle.location.line
98
+ end
99
+
100
+ def build_step(pickle_step)
101
+ step_id = pickle_step.ast_node_ids.first
102
+ Step.new(
103
+ text: pickle_step.text,
104
+ keyword: @step_keywords[step_id] || "Given",
105
+ line: @step_lines[step_id],
106
+ table: table_from(pickle_step),
107
+ doc_string: doc_from(pickle_step)
108
+ )
109
+ end
110
+
111
+ def table_from(pickle_step)
112
+ table = pickle_step.argument&.data_table
113
+ return nil unless table
114
+
115
+ DataTable.new(table.rows.map { |row| row.cells.map(&:value) })
116
+ end
117
+
118
+ def doc_from(pickle_step)
119
+ pickle_step.argument&.doc_string&.content
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gherkin_system"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # Loads the gem when Rails boots. Does not replace ApplicationSystemTestCase.
5
+ class Railtie < Rails::Railtie
6
+ end
7
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # One executable Pickle.
5
+ Scenario = Data.define(
6
+ :uri,
7
+ :feature_name,
8
+ :name,
9
+ :tags,
10
+ :example_values,
11
+ :line,
12
+ :scenario_line,
13
+ :steps
14
+ ) do
15
+ # @return [String] Minitest-facing scenario label
16
+ def test_name
17
+ label = "#{feature_name}: #{name}"
18
+ return label if example_values.empty?
19
+
20
+ "#{label} [#{example_values.join(", ")}]"
21
+ end
22
+
23
+ # @param number [Integer]
24
+ # @return [Boolean]
25
+ def covers_line?(number)
26
+ lines.include?(number)
27
+ end
28
+
29
+ # @return [Array<Integer>]
30
+ def lines
31
+ [line, scenario_line, *steps.map(&:line)].compact.uniq
32
+ end
33
+ end
34
+
35
+ # One step inside a {Scenario}.
36
+ Step = Data.define(:text, :keyword, :line, :table, :doc_string)
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # Object yielded to hooks. Around hooks continue the scenario with {#run}.
5
+ class ScenarioRun
6
+ # @return [String]
7
+ attr_reader :name
8
+
9
+ # @return [String]
10
+ attr_reader :feature_name
11
+
12
+ # @return [Array<String>]
13
+ attr_reader :tags
14
+
15
+ # @return [Exception, nil]
16
+ attr_accessor :exception
17
+
18
+ # @return [Symbol]
19
+ attr_accessor :status
20
+
21
+ # @param scenario [Scenario]
22
+ def initialize(scenario)
23
+ @name = scenario.name
24
+ @feature_name = scenario.feature_name
25
+ @tags = scenario.tags
26
+ @status = :pending
27
+ @exception = nil
28
+ end
29
+
30
+ # Continues the hook chain or the scenario body.
31
+ #
32
+ # @return [void]
33
+ def run
34
+ @continuation.call
35
+ end
36
+
37
+ # @return [void]
38
+ def continue_with(&block)
39
+ @continuation = block
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # Builds a step-definition snippet for an undefined step.
5
+ module Snippet
6
+ module_function
7
+
8
+ # @param step [Step]
9
+ # @return [String]
10
+ def for(step)
11
+ pattern, names = pattern_for(step.text)
12
+ args = names.empty? ? "" : " |#{names.join(", ")}|"
13
+ "#{step.keyword}(\"#{pattern}\") do#{args}\n pending\nend"
14
+ end
15
+
16
+ def pattern_for(text)
17
+ names = []
18
+ pattern = text.gsub(/"[^"]*"|'[^']*'/) { names << "string" and "{string}" }
19
+ pattern = pattern.gsub(/\b\d+\b/) { names << "int" and "{int}" }
20
+ [pattern, names]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cucumber/cucumber_expressions/cucumber_expression"
4
+ require "cucumber/cucumber_expressions/regular_expression"
5
+
6
+ module GherkinSystem
7
+ # One registered Given/When/Then block.
8
+ class StepDefinition
9
+ # @return [String, Regexp]
10
+ attr_reader :pattern
11
+
12
+ # @return [Proc]
13
+ attr_reader :block
14
+
15
+ # @return [Array(String, Integer)]
16
+ attr_reader :location
17
+
18
+ # @param pattern [String, Regexp]
19
+ # @param block [Proc]
20
+ # @param expression [Object]
21
+ # @param location [Array(String, Integer)]
22
+ def initialize(pattern, block, expression, location)
23
+ @pattern = pattern
24
+ @block = block
25
+ @expression = expression
26
+ @location = location
27
+ end
28
+
29
+ # @param text [String]
30
+ # @return [Boolean]
31
+ def matches?(text)
32
+ !match(text).nil?
33
+ end
34
+
35
+ # @param text [String]
36
+ # @param test [Object]
37
+ # @return [Array]
38
+ def captures(text, test)
39
+ match(text).map { |argument| argument.value(test) }
40
+ end
41
+
42
+ # @return [String]
43
+ def location_label
44
+ "#{location[0]}:#{location[1]}"
45
+ end
46
+
47
+ private
48
+
49
+ def match(text)
50
+ @expression.match(text)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # Step patterns registered by {Steps}.
5
+ class StepRegistry
6
+ def initialize(parameter_registry)
7
+ @parameter_registry = parameter_registry
8
+ @definitions = []
9
+ @keys = {}
10
+ end
11
+
12
+ # @param pattern [String, Regexp]
13
+ # @param block [Proc]
14
+ # @return [StepDefinition]
15
+ def register(pattern, &block)
16
+ key = pattern.is_a?(Regexp) ? pattern.inspect : pattern.to_s
17
+ raise DuplicateStep, "Duplicate step definition: #{key}" if @keys.key?(key)
18
+
19
+ definition = StepDefinition.new(pattern, block, expression_for(pattern), block.source_location)
20
+ @keys[key] = definition
21
+ @definitions << definition
22
+ definition
23
+ end
24
+
25
+ # @param step [Step]
26
+ # @param scenario [Scenario]
27
+ # @return [StepDefinition]
28
+ def resolve(step, scenario)
29
+ matches = @definitions.select { |definition| definition.matches?(step.text) }
30
+ return matches.first if matches.one?
31
+ raise UndefinedStep.new(scenario, step) if matches.empty?
32
+
33
+ raise AmbiguousStep.new(scenario, step, matches)
34
+ end
35
+
36
+ private
37
+
38
+ def expression_for(pattern)
39
+ registry = @parameter_registry.registry
40
+ if pattern.is_a?(Regexp)
41
+ Cucumber::CucumberExpressions::RegularExpression.new(pattern, registry)
42
+ else
43
+ Cucumber::CucumberExpressions::CucumberExpression.new(pattern.to_s, registry)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # DSL extended by step-definition modules.
5
+ #
6
+ # @example
7
+ # module AuthenticationSteps
8
+ # extend GherkinSystem::Steps
9
+ #
10
+ # Given("I am signed in") { sign_in users(:customer) }
11
+ # end
12
+ module Steps
13
+ # @param pattern [String, Regexp]
14
+ # @return [StepDefinition]
15
+ def Given(pattern, &)
16
+ GherkinSystem.step_registry.register(pattern, &)
17
+ end
18
+
19
+ alias When Given
20
+ alias Then Given
21
+ alias And Given
22
+ alias But Given
23
+
24
+ # @param name [String]
25
+ # @param regexp [Regexp, String]
26
+ # @param type [Class]
27
+ # @param transformer [Proc]
28
+ # @return [void]
29
+ def ParameterType(name:, regexp:, transformer:, type: String, prefer_for_regexp_match: false)
30
+ GherkinSystem.parameter_registry.define(
31
+ name: name,
32
+ regexp: regexp,
33
+ transformer: transformer,
34
+ type: type,
35
+ prefer_for_regexp_match: prefer_for_regexp_match
36
+ )
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cucumber/tag_expressions"
4
+
5
+ module GherkinSystem
6
+ # Selects scenarios with a Cucumber tag expression.
7
+ module TagFilter
8
+ module_function
9
+
10
+ # @param scenarios [Array<Scenario>]
11
+ # @param expression [String, nil]
12
+ # @return [Array<Scenario>]
13
+ def apply(scenarios, expression)
14
+ return scenarios if expression.nil? || expression.strip.empty?
15
+
16
+ parsed = Cucumber::TagExpressions::Parser.new.parse(expression)
17
+ scenarios.select { |scenario| parsed.evaluate(scenario.tags) }
18
+ rescue CompilationError
19
+ raise
20
+ rescue StandardError => e
21
+ raise CompilationError, "Invalid tag expression #{expression.inspect}: #{e.message}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module GherkinSystem
6
+ # Namespace for generated test classes.
7
+ module Suite
8
+ # Removes generated classes. Used by tests.
9
+ #
10
+ # @return [void]
11
+ def self.reset!
12
+ constants(false).each { |name| remove_const(name) }
13
+ end
14
+ end
15
+
16
+ # Defines one Minitest class per feature file.
17
+ class TestClassBuilder
18
+ # @param base [Class]
19
+ def initialize(base)
20
+ @base = base
21
+ end
22
+
23
+ # @param scenarios [Array<Scenario>]
24
+ # @return [Array<Class>]
25
+ def build(scenarios)
26
+ scenarios.group_by(&:uri).map { |uri, group| build_class(uri, group) }
27
+ end
28
+
29
+ private
30
+
31
+ def build_class(uri, scenarios)
32
+ klass = Class.new(@base)
33
+ assign_constant(klass, scenarios.first.feature_name, uri)
34
+ include_step_modules(klass)
35
+ record_scenarios(klass, define_tests(klass, scenarios))
36
+ define_preflight(klass)
37
+ klass
38
+ end
39
+
40
+ def assign_constant(klass, feature_name, uri)
41
+ name = constant_name(feature_name, uri)
42
+ Suite.send(:remove_const, name) if Suite.const_defined?(name, false)
43
+ Suite.const_set(name, klass)
44
+ end
45
+
46
+ def constant_name(feature_name, uri)
47
+ stem = feature_name.to_s.scan(/[A-Za-z0-9]+/).map(&:capitalize).join
48
+ stem = "Feature#{stem}" unless stem.match?(/\A[A-Z]/)
49
+ suffix = Digest::SHA1.hexdigest(uri.to_s)[0, 6]
50
+ "#{stem}#{suffix}Test"
51
+ end
52
+
53
+ def include_step_modules(klass)
54
+ GherkinSystem.config.step_modules.each { |mod| klass.include(mod) }
55
+ end
56
+
57
+ def define_tests(klass, scenarios)
58
+ scenarios.each_with_index.to_h do |scenario, index|
59
+ method_name = method_name_for(scenario, index)
60
+ klass.define_method(method_name) { GherkinSystem::Executor.call(self) }
61
+ [method_name, scenario]
62
+ end
63
+ end
64
+
65
+ def method_name_for(scenario, index)
66
+ slug = scenario.test_name.gsub(/[^A-Za-z0-9]+/, "_").gsub(/\A_|_\z/, "")
67
+ slug = "scenario" if slug.empty?
68
+ "test_#{slug}_#{index}"
69
+ end
70
+
71
+ def record_scenarios(klass, scenarios_by_method)
72
+ klass.define_singleton_method(:gherkin_scenarios) { scenarios_by_method }
73
+ end
74
+
75
+ def define_preflight(klass)
76
+ klass.define_method(:before_setup) do
77
+ GherkinSystem::Executor.validate!(self) if GherkinSystem.config.strict
78
+ super() if defined?(super)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GherkinSystem
4
+ # Gem version.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gherkin_system/version"
4
+ require_relative "gherkin_system/errors"
5
+ require_relative "gherkin_system/configuration"
6
+ require_relative "gherkin_system/data_table"
7
+ require_relative "gherkin_system/scenario"
8
+ require_relative "gherkin_system/snippet"
9
+ require_relative "gherkin_system/parameter_registry"
10
+ require_relative "gherkin_system/step_definition"
11
+ require_relative "gherkin_system/step_registry"
12
+ require_relative "gherkin_system/steps"
13
+ require_relative "gherkin_system/hooks"
14
+ require_relative "gherkin_system/tag_filter"
15
+ require_relative "gherkin_system/parser"
16
+ require_relative "gherkin_system/feature_loader"
17
+ require_relative "gherkin_system/test_class_builder"
18
+ require_relative "gherkin_system/scenario_run"
19
+ require_relative "gherkin_system/executor"
20
+ require_relative "gherkin_system/checker"
21
+ require_relative "gherkin_system/cli"
22
+
23
+ # Compiles Gherkin features into Minitest methods on the application's system test class.
24
+ #
25
+ # @example Load features
26
+ # GherkinSystem.load!(base: ApplicationSystemTestCase)
27
+ module GherkinSystem
28
+ class << self
29
+ # @return [Configuration]
30
+ def config
31
+ @config ||= Configuration.new
32
+ end
33
+
34
+ # @yieldparam config [Configuration]
35
+ # @return [void]
36
+ def configure
37
+ yield(config)
38
+ end
39
+
40
+ # Clears configuration, step definitions, hooks, and generated test classes.
41
+ #
42
+ # @return [void]
43
+ def reset_configuration!
44
+ @config = nil
45
+ @step_registry = nil
46
+ @parameter_registry = nil
47
+ @hook_registry = nil
48
+ Suite.reset!
49
+ end
50
+
51
+ # @param glob [String, Pathname, nil]
52
+ # @param base [Class, nil] system test superclass. Required when config.base_test_class is unset.
53
+ # @return [Array<Class>] generated test classes
54
+ def load!(glob = nil, base: nil)
55
+ test_class = base || config.base_test_class
56
+ raise ConfigurationError, "Pass base: or set config.base_test_class" if test_class.nil?
57
+
58
+ scenarios = TagFilter.apply(FeatureLoader.load(glob), config.tags)
59
+ TestClassBuilder.new(test_class).build(scenarios)
60
+ end
61
+
62
+ # @return [StepRegistry]
63
+ def step_registry
64
+ @step_registry ||= StepRegistry.new(parameter_registry)
65
+ end
66
+
67
+ # @return [ParameterRegistry]
68
+ def parameter_registry
69
+ @parameter_registry ||= ParameterRegistry.new
70
+ end
71
+
72
+ # @return [HookRegistry]
73
+ def hook_registry
74
+ @hook_registry ||= HookRegistry.new
75
+ end
76
+ end
77
+ end
78
+
79
+ require_relative "gherkin_system/railtie" if defined?(Rails::Railtie)