diff --git a/.github/workflows/release-rubygem.yaml b/.github/workflows/release-rubygem.yaml new file mode 100644 index 00000000..c4f47d15 --- /dev/null +++ b/.github/workflows/release-rubygem.yaml @@ -0,0 +1,29 @@ +name: Release RubyGems + +permissions: {} + +on: + push: + branches: + - release/* + +jobs: + publish-rubygem: + name: Publish Ruby Gem + runs-on: ubuntu-latest + environment: Release + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 + with: + ruby-version: 4.0.5 + working-directory: ruby + - uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 + - uses: cucumber/action-publish-rubygem@4e79bb9aed597c835e8438f57c04d0996ab80d72 # v2.0.0 + with: + working-directory: ruby diff --git a/.github/workflows/test-ruby.yaml b/.github/workflows/test-ruby.yaml new file mode 100644 index 00000000..dadc993d --- /dev/null +++ b/.github/workflows/test-ruby.yaml @@ -0,0 +1,48 @@ +name: test-ruby + +permissions: {} + +on: + push: + branches: + - main + - renovate/** + paths: + - ruby/** + - testdata/** + - .github/** + pull_request: + branches: + - main + - renovate/** + paths: + - ruby/** + - testdata/** + - .github/** + +jobs: + test-ruby: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: + - ubuntu-latest + ruby: ['3.3', '3.4', '4.0'] + include: + - os: macos-latest + ruby: '4.0' + - os: windows-latest + ruby: '4.0' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + working-directory: ruby + - run: bundle exec rspec + working-directory: ruby diff --git a/CHANGELOG.md b/CHANGELOG.md index afbb2764..98c53ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- [Ruby] Add initial Ruby implementation and RubyGems release workflow, covering the needs of the rerun formatter only for now ([#98](https://github.com/cucumber/query/pull/98)) + ### Changed - Prefer Pickle.Location with fallback to Lineage ([#175](https://github.com/cucumber/query/pull/175)) diff --git a/README.md b/README.md index 4a33b541..72c840f3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ maven-central + + rubygems + build @@ -38,3 +41,4 @@ is rendering the [GherkinDocument](https://github.com/cucumber/messages/blob/mai The Query library makes this easy by providing a function to look up the status of a step, a scenario or an entire file. + diff --git a/ruby/.gitignore b/ruby/.gitignore new file mode 100644 index 00000000..7ae6fcfb --- /dev/null +++ b/ruby/.gitignore @@ -0,0 +1,2 @@ +Gemfile.lock +*.gem diff --git a/ruby/.rspec b/ruby/.rspec new file mode 100644 index 00000000..3687797e --- /dev/null +++ b/ruby/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--color diff --git a/ruby/.rubocop.yml b/ruby/.rubocop.yml new file mode 100644 index 00000000..f587f878 --- /dev/null +++ b/ruby/.rubocop.yml @@ -0,0 +1,21 @@ +inherit_from: .rubocop_todo.yml + +plugins: + - rubocop-performance + - rubocop-rspec + +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 3.3 + NewCops: enable + +# Stylistic preference for cucumber +Gemspec/DevelopmentDependencies: + Enabled: false + +# Disabled on our repo's to enable polyglot-release +Gemspec/RequireMFA: + Enabled: false diff --git a/ruby/.rubocop_todo.yml b/ruby/.rubocop_todo.yml new file mode 100644 index 00000000..aec52a07 --- /dev/null +++ b/ruby/.rubocop_todo.yml @@ -0,0 +1,133 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2025-09-05 19:34:38 UTC using RuboCop version 1.80.2. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines +Layout/EmptyLinesAroundModuleBody: + Exclude: + - 'spec/support/runner_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowInHeredoc. +Layout/TrailingWhitespace: + Exclude: + - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb' + +# Offense count: 8 +# Configuration parameters: AllowComments, AllowEmptyLambdas. +Lint/EmptyBlock: + Exclude: + - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb' + - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb' + +# Offense count: 8 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError +Lint/UnusedMethodArgument: + Exclude: + - 'lib/cucumber/query/query.rb' + +# Offense count: 4 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. +Metrics/AbcSize: + Max: 43 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 14 + +# Offense count: 2 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/ClassLength: + Max: 160 + +# Offense count: 3 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Max: 24 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 14 + +# Offense count: 8 +Performance/MethodObjectAsBlock: + Exclude: + - 'lib/cucumber/query/legacy/hook_by_test_step.rb' + - 'lib/cucumber/query/legacy/pickle_by_test.rb' + - 'lib/cucumber/query/legacy/pickle_step_by_test_step.rb' + - 'lib/cucumber/query/legacy/step_definitions_by_test_step.rb' + - 'lib/cucumber/query/legacy/test_case_started_by_test_case.rb' + +# Offense count: 1 +# Configuration parameters: Prefixes, AllowedPatterns. +# Prefixes: when, with, without +RSpec/ContextWording: + Exclude: + - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb' + +# Offense count: 1 +# Configuration parameters: CountAsOne. +RSpec/ExampleLength: + Max: 7 + +# Offense count: 32 +# Configuration parameters: AssignmentOnly. +RSpec/InstanceVariable: + Exclude: + - 'spec/cucumber/query/legacy/hook_by_test_step_spec.rb' + - 'spec/cucumber/query/legacy/pickle_by_test_spec.rb' + - 'spec/cucumber/query/legacy/pickle_step_by_test_step_spec.rb' + - 'spec/cucumber/query/legacy/step_definitions_by_test_step_spec.rb' + - 'spec/cucumber/query/legacy/test_case_started_by_test_case_spec.rb' + +# Offense count: 3 +RSpec/MultipleExpectations: + Max: 3 + +# Offense count: 12 +# Configuration parameters: AllowedGroups. +RSpec/NestedGroups: + Max: 5 + +# Offense count: 8 +# Configuration parameters: AllowedConstants. +Style/Documentation: + Exclude: + - 'lib/cucumber/query/helpers.rb' + - 'lib/cucumber/query/legacy/hook_by_test_step.rb' + - 'lib/cucumber/query/legacy/pickle_by_test.rb' + - 'lib/cucumber/query/legacy/pickle_step_by_test_step.rb' + - 'lib/cucumber/query/legacy/step_definitions_by_test_step.rb' + - 'lib/cucumber/query/legacy/test_case_started_by_test_case.rb' + - 'lib/cucumber/query/legacy/test_run_started.rb' + - 'lib/cucumber/query/query.rb' + +# Offense count: 4 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: always, always_true, never +Style/FrozenStringLiteralComment: + Exclude: + - 'lib/cucumber/query/helpers.rb' + - 'lib/cucumber/query/query.rb' + - 'spec/support/gherkin_helper.rb' + - 'spec/support/runner_helper.rb' + +# Offense count: 8 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Max: 198 diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 00000000..7f4f5e95 --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec diff --git a/ruby/LICENSE b/ruby/LICENSE new file mode 100644 index 00000000..d23a133d --- /dev/null +++ b/ruby/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Cucumber Ltd and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ruby/VERSION b/ruby/VERSION new file mode 100644 index 00000000..946789e6 --- /dev/null +++ b/ruby/VERSION @@ -0,0 +1 @@ +16.0.0 diff --git a/ruby/cucumber-query.gemspec b/ruby/cucumber-query.gemspec new file mode 100644 index 00000000..c2244113 --- /dev/null +++ b/ruby/cucumber-query.gemspec @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +Gem::Specification.new do |s| + s.name = 'cucumber-query' + s.version = File.read(File.expand_path('VERSION', __dir__)).strip + s.authors = ['Luke Hill'] + s.description = 'Cucumber Query - query messages' + s.summary = "#{s.name}-#{s.version}" + s.email = 'cukebot@cucumber.io' + s.homepage = 'https://github.com/cucumber/query' + s.platform = Gem::Platform::RUBY + s.license = 'MIT' + s.required_ruby_version = '>= 3.3' + s.required_rubygems_version = '>= 3.2.8' + + s.metadata = { + 'bug_tracker_uri' => 'https://github.com/cucumber/query/issues', + 'changelog_uri' => 'https://github.com/cucumber/query/blob/main/CHANGELOG.md', + 'documentation_uri' => 'https://github.com/cucumber/query/blob/main/README.md', + 'mailing_list_uri' => 'https://groups.google.com/forum/#!forum/cukes', + 'source_code_uri' => 'https://github.com/cucumber/query/blob/main/ruby' + } + + s.add_dependency 'cucumber-messages', '> 34', '< 36' + + s.add_development_dependency 'rspec', '~> 3.13' + s.add_development_dependency 'rubocop', '~> 1.88.0' + s.add_development_dependency 'rubocop-performance', '~> 1.26.1' + s.add_development_dependency 'rubocop-rspec', '~> 3.10.2' + + s.files = Dir['LICENSE', 'VERSION', 'lib/**/*'] + s.rdoc_options = ['--charset=UTF-8'] + s.require_path = 'lib' +end diff --git a/ruby/lib/cucumber/query.rb b/ruby/lib/cucumber/query.rb new file mode 100644 index 00000000..40d5da7b --- /dev/null +++ b/ruby/lib/cucumber/query.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require 'cucumber/messages' + +require_relative 'repository' + +# Given one Cucumber Message, find another. +# +# Queries can be made while the test run is incomplete - and this will naturally return incomplete results +# see {Cucumber Messages - Message Overview}[https://github.com/cucumber/messages?tab=readme-ov-file#message-overview] +# +module Cucumber + # Provides lookup methods for related Cucumber messages stored in the `Repository` class. + class Query + attr_reader :repository + private :repository + + include Cucumber::Messages::Helpers::TimeConversion + include Cucumber::Messages::Helpers::TestStepResultComparator + + def initialize(repository) + @repository = repository + end + + def count_test_cases_started + find_all_test_case_started.length + end + + def find_all_pickles + repository.pickle_by_id.values + end + + def find_all_pickle_steps + repository.pickle_step_by_id.values + end + + def find_all_step_definitions + repository.step_definition_by_id.values + end + + # This finds all test cases from the following conditions (UNION) + # -> Test cases that have started, but not yet finished + # -> Test cases that have started, finished, but that will NOT be retried + def find_all_test_case_started + repository.test_case_started_by_id.values.select do |test_case_started| + test_case_finished = find_test_case_finished_by(test_case_started) + test_case_finished.nil? || !test_case_finished.will_be_retried + end + end + + # This finds all test cases that have finished AND will not be retried + def find_all_test_case_finished + repository.test_case_finished_by_test_case_started_id.values.reject(&:will_be_retried) + end + + def find_all_test_cases + repository.test_case_by_id.values + end + + def find_all_test_run_hook_started + repository.test_run_hook_started_by_id.values + end + + def find_all_test_run_hook_finished + repository.test_run_hook_finished_by_test_run_hook_started_id.values + end + + def find_all_test_step_started + repository.test_steps_started_by_test_case_started_id.values.flatten + end + + def find_all_test_step_finished + repository.test_steps_finished_by_test_case_started_id.values.flatten + end + + def find_all_test_steps + repository.test_step_by_id.values + end + + # This method will be called with 1 of these 3 messages + # [TestStep || TestRunHookStarted || TestRunHookFinished] + def find_hook_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestStep, Cucumber::Messages::TestRunHookStarted, Cucumber::Messages::TestRunHookFinished], + '#find_hook_by' + ) + + if message.is_a?(Cucumber::Messages::TestRunHookFinished) + test_run_hook_started_message = find_test_run_hook_started_by(message) + test_run_hook_started_message ? find_hook_by(test_run_hook_started_message) : nil + else + repository.hook_by_id[message.hook_id] + end + end + + def find_meta + repository.meta + end + + # This method will be called with 1 of these 2 messages + # [TestCaseStarted || TestCaseFinished] + def find_most_severe_test_step_result_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished], + '#find_most_severe_test_step_result_by' + ) + + if message.is_a?(Cucumber::Messages::TestCaseStarted) + find_test_steps_finished_by(message) + .map(&:test_step_result) + .max_by { |test_step_result| test_step_result_rankings[test_step_result.status] } + else + test_case_started_message = find_test_case_started_by(message) + test_case_started_message && find_most_severe_test_step_result_by(test_case_started_message) + end + end + + # This method will be called with 1 of these 5 messages + # [TestCase || TestCaseStarted || TestCaseFinished || TestStepStarted || TestStepFinished] + def find_pickle_by(message) + ensure_only_message_types!( + message, + [ + Cucumber::Messages::TestCase, + Cucumber::Messages::TestCaseStarted, + Cucumber::Messages::TestCaseFinished, + Cucumber::Messages::TestStepStarted, + Cucumber::Messages::TestStepFinished + ], + '#find_pickle_by' + ) + + test_case_message = message.is_a?(Cucumber::Messages::TestCase) ? message : find_test_case_by(message) + repository.pickle_by_id[test_case_message.pickle_id] + end + + # This method will be called with only 1 message + # [TestStep] + def find_pickle_step_by(test_step) + ensure_only_message_types!( + test_step, + [Cucumber::Messages::TestStep], + '#find_pickle_step_by' + ) + + repository.pickle_step_by_id[test_step.pickle_step_id] + end + + # This method will be called with only 1 message + # [PickleStep] + def find_step_by(pickle_step) + ensure_only_message_types!( + pickle_step, + [Cucumber::Messages::PickleStep], + '#find_step_by' + ) + + repository.step_by_id[pickle_step.ast_node_ids.first] + end + + # This method will be called with only 1 message + # [TestStep] + def find_step_definitions_by(test_step) + ensure_only_message_types!( + test_step, + [Cucumber::Messages::TestStep], + '#find_step_definitions_by' + ) + + ids = test_step.step_definition_ids.nil? ? [] : test_step.step_definition_ids + ids.filter_map { |id| repository.step_definition_by_id[id] } + end + + # This method will be called with 1 of these 4 messages + # [TestCaseStarted || TestCaseFinished || TestStepStarted || TestStepFinished] + def find_test_case_by(message) + ensure_only_message_types!( + message, + [ + Cucumber::Messages::TestCaseStarted, + Cucumber::Messages::TestCaseFinished, + Cucumber::Messages::TestStepStarted, + Cucumber::Messages::TestStepFinished + ], + '#find_test_case_by' + ) + + test_case_started_message = message.is_a?(Cucumber::Messages::TestCaseStarted) ? message : find_test_case_started_by(message) + repository.test_case_by_id[test_case_started_message.test_case_id] + end + + # This method will be called with 1 of these 3 messages + # [TestCaseFinished || TestStepStarted || TestStepFinished] + def find_test_case_started_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestCaseFinished, Cucumber::Messages::TestStepStarted, Cucumber::Messages::TestStepFinished], + '#find_test_case_started_by' + ) + + repository.test_case_started_by_id[message.test_case_started_id] + end + + # This method will be called with only 1 message + # [TestCaseStarted] + def find_test_case_finished_by(test_case_started) + ensure_only_message_types!( + test_case_started, + [Cucumber::Messages::TestCaseStarted], + '#find_test_case_finished_by' + ) + + repository.test_case_finished_by_test_case_started_id[test_case_started.id] + end + + def find_test_run_duration + if repository.test_run_started.nil? || repository.test_run_finished.nil? + nil + else + timestamp_to_time(repository.test_run_finished.timestamp) - timestamp_to_time(repository.test_run_started.timestamp) + end + end + + # This method will be called with only 1 message + # [TestRunHookFinished] + def find_test_run_hook_started_by(test_run_hook_finished) + ensure_only_message_types!( + test_run_hook_finished, + [Cucumber::Messages::TestRunHookFinished], + '#find_test_run_hook_started_by' + ) + + repository.test_run_hook_started_by_id[test_run_hook_finished.test_run_hook_started_id] + end + + # This method will be called with only 1 message + # [TestRunHookStarted] + def find_test_run_hook_finished_by(test_run_hook_started) + ensure_only_message_types!( + test_run_hook_started, + [Cucumber::Messages::TestRunHookStarted], + '#find_test_run_hook_finished_by' + ) + + repository.test_run_hook_finished_by_test_run_hook_started_id[test_run_hook_started.id] + end + + def find_test_run_started + repository.test_run_started + end + + def find_test_run_finished + repository.test_run_finished + end + + # This method will be called with 1 of these 2 messages + # [TestStepStarted || TestStepFinished] + def find_test_step_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestStepStarted, Cucumber::Messages::TestStepFinished], + '#find_test_step_by' + ) + + repository.test_step_by_id[message.test_step_id] + end + + # This method will be called with 1 of these 2 messages + # [TestCaseStarted || TestCaseFinished] + def find_test_steps_started_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished], + '#find_test_steps_started_by' + ) + + key = message.is_a?(Cucumber::Messages::TestCaseStarted) ? message.id : message.test_case_started_id + repository.test_steps_started_by_test_case_started_id.fetch(key, []) + end + + # This method will be called with 1 of these 2 messages + # [TestCaseStarted || TestCaseFinished] + def find_test_steps_finished_by(message) + ensure_only_message_types!( + message, + [Cucumber::Messages::TestCaseStarted, Cucumber::Messages::TestCaseFinished], + '#find_test_steps_finished_by' + ) + + if message.is_a?(Cucumber::Messages::TestCaseStarted) + repository.test_steps_finished_by_test_case_started_id.fetch(message.id, []) + else + test_case_started_message = find_test_case_started_by(message) + test_case_started_message.nil? ? [] : find_test_steps_finished_by(test_case_started_message) + end + end + + private + + def ensure_only_message_types!(supplied_message, permissible_message_types, method_name) + raise ArgumentError, "Supplied argument is not a Cucumber Message. Argument: #{supplied_message.class}" unless supplied_message.is_a?(Cucumber::Messages::Message) + raise ArgumentError, "Supplied message type '#{supplied_message.class}' is not permitted to be used when calling #{method_name}" unless permissible_message_types.include?(supplied_message.class) + end + end +end diff --git a/ruby/lib/cucumber/repository.rb b/ruby/lib/cucumber/repository.rb new file mode 100644 index 00000000..793296b2 --- /dev/null +++ b/ruby/lib/cucumber/repository.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +module Cucumber + # In memory repository i.e. a thread based link to cucumber-query + class Repository + attr_accessor :meta, :test_run_started, :test_run_finished + attr_reader :attachments_by_test_case_started_id, :attachments_by_test_run_hook_started_id, + :hook_by_id, + :pickle_by_id, :pickle_step_by_id, + :step_by_id, :step_definition_by_id, + :test_case_by_id, :test_case_started_by_id, :test_case_finished_by_test_case_started_id, + :test_run_hook_started_by_id, :test_run_hook_finished_by_test_run_hook_started_id, + :test_step_by_id, :test_steps_started_by_test_case_started_id, :test_steps_finished_by_test_case_started_id + + def initialize + @attachments_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] } + @attachments_by_test_run_hook_started_id = Hash.new { |hash, key| hash[key] = [] } + @hook_by_id = {} + @pickle_by_id = {} + @pickle_step_by_id = {} + @step_by_id = {} + @step_definition_by_id = {} + @test_case_by_id = {} + @test_case_started_by_id = {} + @test_case_finished_by_test_case_started_id = {} + @test_run_hook_started_by_id = {} + @test_run_hook_finished_by_test_run_hook_started_id = {} + @test_step_by_id = {} + @test_steps_started_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] } + @test_steps_finished_by_test_case_started_id = Hash.new { |hash, key| hash[key] = [] } + end + + def update(envelope) + message_category = envelope.type[:contained_message] + message_value = envelope.public_send(message_category) + send("update_#{message_category}", message_value) + end + + private + + # Missing handlers + + def method_missing(method_name, *args, &) + if method_name.to_s.start_with?('update_') + Kernel.warn("Attempting to update the repository with #{method_name}. Please raise this as a missing message handler") + Kernel.warn('Create an issue here: https://github.com/cucumber/query/issues') + nil + else + super + end + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.start_with?('update_') || super + end + + def update_source(_source) + :no_op # Not Implemented Yet. But method will be inherently called from `#update` + end + + def update_undefined_parameter_type(_undefined_parameter_type) + :no_op # Not Implemented Yet. But method will be inherently called from `#update` + end + + def update_meta(meta) + self.meta = meta + end + + def update_attachment(attachment) + attachments_by_test_case_started_id[attachment.test_case_started_id] << attachment if attachment.test_case_started_id + attachments_by_test_run_hook_started_id[attachment.test_run_hook_started_id] << attachment if attachment.test_run_hook_started_id + end + + def update_feature(feature) + feature.children.each do |feature_child| + update_steps(feature_child.background.steps) if feature_child.background + update_scenario(feature_child.scenario) if feature_child.scenario + next unless feature_child.rule + + feature_child.rule.children.each do |rule_child| + update_steps(rule_child.background.steps) if rule_child.background + update_scenario(rule_child.scenario) if rule_child.scenario + end + end + end + + def update_gherkin_document(gherkin_document) + update_feature(gherkin_document.feature) if gherkin_document.feature + end + + def update_hook(hook) + hook_by_id[hook.id] = hook + end + + def update_pickle(pickle) + pickle_by_id[pickle.id] = pickle + pickle.steps.each { |pickle_step| pickle_step_by_id[pickle_step.id] = pickle_step } + end + + def update_scenario(scenario) + update_steps(scenario.steps) + end + + def update_steps(steps) + steps.each { |step| step_by_id[step.id] = step } + end + + def update_step_definition(step_definition) + step_definition_by_id[step_definition.id] = step_definition + end + + def update_test_case(test_case) + test_case_by_id[test_case.id] = test_case + test_case.test_steps.each { |test_step| test_step_by_id[test_step.id] = test_step } + end + + def update_test_case_started(test_case_started) + test_case_started_by_id[test_case_started.id] = test_case_started + end + + def update_test_case_finished(test_case_finished) + test_case_finished_by_test_case_started_id[test_case_finished.test_case_started_id] = test_case_finished + end + + def update_test_run_started(test_run_started) + self.test_run_started = test_run_started + end + + def update_test_run_finished(test_run_finished) + self.test_run_finished = test_run_finished + end + + def update_test_run_hook_started(test_run_hook_started) + test_run_hook_started_by_id[test_run_hook_started.id] = test_run_hook_started + end + + def update_test_run_hook_finished(test_run_hook_finished) + test_run_hook_finished_by_test_run_hook_started_id[test_run_hook_finished.test_run_hook_started_id] = test_run_hook_finished + end + + def update_test_step_started(test_step_started) + test_steps_started_by_test_case_started_id[test_step_started.test_case_started_id] << test_step_started + end + + def update_test_step_finished(test_step_finished) + test_steps_finished_by_test_case_started_id[test_step_finished.test_case_started_id] << test_step_finished + end + end +end diff --git a/ruby/spec/cucumber/query_spec.rb b/ruby/spec/cucumber/query_spec.rb new file mode 100644 index 00000000..85eea83f --- /dev/null +++ b/ruby/spec/cucumber/query_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.describe Cucumber::Query do + def testdata_path(name) + File.expand_path("../../../testdata/src/#{name}", __dir__) + end + + def parse_ndjson_file(path) + Cucumber::Messages::Helpers::NdjsonToMessageEnumerator.new(File.read(path)) + end + + queries = { + 'countTestCasesStarted' => ->(query) { query.count_test_cases_started }, + 'findAllPickles' => ->(query) { query.find_all_pickles.length }, + 'findAllStepDefinitions' => ->(query) { query.find_all_step_definitions.length }, + 'findAllTestCases' => ->(query) { query.find_all_test_cases.length }, + 'findAllTestRunHookStarted' => ->(query) { query.find_all_test_run_hook_started.length }, + 'findAllTestRunHookFinished' => ->(query) { query.find_all_test_run_hook_finished.length }, + 'findAllTestSteps' => ->(query) { query.find_all_test_steps.length }, + 'findMeta' => ->(query) { query.find_meta.implementation.name }, + 'findPickleBy' => lambda do |query| + results = {} + results['testCaseStarted'] = query.find_all_test_case_started.map { |message| query.find_pickle_by(message).name } + results['testCaseFinished'] = query.find_all_test_case_finished.map { |message| query.find_pickle_by(message).name } + results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_pickle_by(message).name } + results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_pickle_by(message).name } + results + end, + 'findTestCaseBy' => lambda do |query| + results = {} + results['testCaseStarted'] = query.find_all_test_case_started.map { |message| query.find_test_case_by(message).id } + results['testCaseFinished'] = query.find_all_test_case_finished.map { |message| query.find_test_case_by(message).id } + results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_test_case_by(message).id } + results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_test_case_by(message).id } + results + end, + 'findTestStepBy' => lambda do |query| + results = {} + results['testStepStarted'] = query.find_all_test_step_started.map { |message| query.find_test_step_by(message).id } + results['testStepFinished'] = query.find_all_test_step_finished.map { |message| query.find_test_step_by(message).id } + results + end + } + + sources = %w[ + attachments + empty + examples-tables + global-hooks + global-hooks-attachments + hooks + minimal + rules + unknown-parameter-type + ] + + subject(:query) { described_class.new(repository) } + + let(:repository) { Cucumber::Repository.new } + + sources.product(queries.to_a).each do |source_name, (query_name, query_proc)| + describe "Query: '#{query_name}'. testdata source: '#{source_name}'" do + let(:source) { testdata_path("#{source_name}.ndjson") } + let(:expected_results_file) { testdata_path("#{source_name}.#{query_name}.results.json") } + let(:messages) { parse_ndjson_file(source) } + + before do + messages.each { |message| repository.update(message) } + end + + it 'returns the expected query result' do + evaluated_query = query_proc.call(query) + expected_query_result = JSON.parse(File.read(expected_results_file)) + + expect(evaluated_query).to eq(expected_query_result) + end + end + end +end diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 00000000..77322ec5 --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'cucumber/query' + +require 'json' diff --git a/testdata/README.md b/testdata/README.md index ec760193..7416c957 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -4,7 +4,7 @@ Query uses the examples from the [cucumber compatibility kit](https://github.com for acceptance testing. These examples consist of `.ndjson` files created by the [`fake-cucumber` reference implementation](https://github.com/cucumber/fake-cucumber). -* The `.njdon` files are copied in by running `npm install`. +* The `.ndjson` files are copied in by running `npm install`. * The expected `.xml` files are created by running the tests We ensure the `.ndjson` files stay up to date by running `npm install` in CI