diff --git a/.github/workflows/release-rubygem.yaml b/.github/workflows/release-rubygem.yaml new file mode 100644 index 00000000..cf601f8f --- /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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0 + with: + ruby-version: 4.0.4 + working-directory: ruby + - uses: rubygems/configure-rubygems-credentials@762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.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..00104767 --- /dev/null +++ b/.github/workflows/test-ruby.yaml @@ -0,0 +1,52 @@ +name: test-ruby + +permissions: {} + +on: + push: + branches: + - main + - renovate/** + paths: + - ruby/** + - testdata/** + - .github/** + pull_request: + branches: + - main + paths: + - ruby/** + - testdata/** + - .github/** + +jobs: + test-ruby: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + ruby: ['3.2', '3.3', '3.4', '4.0'] + include: + - os: macos-latest + ruby: '3.4' + - os: windows-latest + ruby: '3.4' + + steps: + - name: set git core.autocrlf to 'input' + run: git config --global core.autocrlf input + + - 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 rake + working-directory: ruby diff --git a/.gitignore b/.gitignore index 1062418c..f82bada9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea/ *.iml + +ruby/Gemfile.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 61913df9..c2fad973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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 Ruby implementation of cucumber-query ([#167](https://github.com/cucumber/query/pull/167)). ## [15.0.1] - 2026-01-22 ### Fixed diff --git a/ruby/.rspec b/ruby/.rspec new file mode 100644 index 00000000..5be63fcb --- /dev/null +++ b/ruby/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--format documentation diff --git a/ruby/.rubocop.yml b/ruby/.rubocop.yml new file mode 100644 index 00000000..f8e753b2 --- /dev/null +++ b/ruby/.rubocop.yml @@ -0,0 +1,25 @@ +plugins: + - rubocop-performance + - rubocop-rake + - rubocop-rspec + +AllCops: + NewCops: enable + TargetRubyVersion: 3.2 + +Layout/EndOfLine: + EnforcedStyle: lf + +Style/Documentation: + Enabled: false + +Gemspec/DevelopmentDependencies: + Enabled: false + +RSpec/MultipleExpectations: + Exclude: + - spec/unit/**/* + +RSpec/SpecFilePathFormat: + Exclude: + - spec/unit/**/* diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 00000000..615540f7 --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'cucumber-messages', path: ENV.fetch('CUCUMBER_MESSAGES_RUBY_PATH') if ENV['CUCUMBER_MESSAGES_RUBY_PATH'] + +gemspec diff --git a/ruby/README.md b/ruby/README.md new file mode 100644 index 00000000..af670aa1 --- /dev/null +++ b/ruby/README.md @@ -0,0 +1,161 @@ +# Cucumber Query for Ruby + +`cucumber-query` builds an in-memory index of Cucumber Messages so formatters +and other message consumers can answer questions such as "which pickle belongs +to this test case?", "which step definition matched this test step?", or "what +was the most severe result for this scenario?". + +The Ruby implementation follows the public API shape used by the other +`cucumber-query` packages while using Ruby snake_case method names. + +## Installation + +```ruby +gem 'cucumber-query', '~> 15.0' +``` + +Ruby 3.2 or newer is required. The gem depends on `cucumber-messages` and +accepts `Cucumber::Messages::Envelope` objects. + +## Basic usage + +Create one query per message stream, then pass every envelope to `#update` in +the order it was produced: + +```ruby +require 'cucumber/query' +require 'cucumber/messages/helpers/ndjson_to_message_enumerator' + +query = Cucumber::Query::Query.new + +File.open('cucumber-messages.ndjson', 'r') do |io| + Cucumber::Messages::Helpers::NdjsonToMessageEnumerator.new(io).each do |envelope| + query.update(envelope) + end +end + +puts query.count_test_cases_started +puts query.find_all_pickles.map(&:name) +``` + +`#update` returns `nil`. After each update, the query indexes the parts of the +envelope it understands and keeps the original envelope in `#envelopes` for +inspection/debugging. + +## Message flow and architecture + +The query is deliberately stateful: + +1. `meta`, `gherkin_document`, `pickle`, `hook`, `step_definition`, `test_case` + and other definition messages populate lookup tables. +2. Runtime messages such as `test_case_started`, `test_step_finished`, + `attachment`, `test_case_finished`, and global hook messages are linked back + to those definitions by id. +3. Query methods read those lookup tables and return message objects from + `cucumber-messages`, not wrapper objects. + +Because Cucumber Messages refer to related data by id, feed the complete stream +to the query when possible. If a formatter asks for a pickle before the +corresponding `pickle` message has been seen, the lookup will return `nil`. + +## Common query methods + +Counts and result aggregation: + +```ruby +query.count_test_cases_started +query.count_most_severe_test_step_result_status +``` + +Top-level collections: + +```ruby +query.find_all_pickles +query.find_all_pickle_steps +query.find_all_step_definitions +query.find_all_test_steps +query.find_all_test_case_started +query.find_all_test_case_finished +query.find_all_test_run_hook_started +query.find_all_test_run_hook_finished +query.find_all_undefined_parameter_types +``` + +Relationship lookups: + +```ruby +query.find_pickle_by(test_case_started_or_finished) +query.find_location_of(pickle) +query.find_lineage_by(pickle_or_runtime_message) +query.find_test_case_by(test_case_started_or_finished) +query.find_test_case_started_by(test_case_finished_or_step_message) +query.find_test_case_finished_by(test_case_started) +query.find_test_step_by(test_step_started_or_finished) +query.find_pickle_step_by(test_step) +query.find_step_by(pickle_step) +query.find_hook_by(test_step_or_test_run_hook_message) +query.find_step_definitions_by(test_step) +query.find_unambiguous_step_definition_by(test_step) +query.find_attachments_by(test_step_finished_or_test_run_hook_finished) +query.find_suggestions_by(pickle_or_pickle_step) +``` + +Duration helpers: + +```ruby +query.find_test_case_duration_by(test_case_started_or_finished) +query.find_test_run_duration +``` + +Ordering helpers: + +```ruby +query.find_all_test_case_started_order_by(->(query, item) { query.find_pickle_by(item).name }, ->(a, b) { a <=> b }) +query.find_all_test_case_finished_order_by(->(query, item) { item.timestamp.seconds }, ->(a, b) { a <=> b }) +``` + +The ordering methods receive a projection callable and a comparison callable. +Items whose projection is `nil` sort after items with a value. + +## Local development dependencies + +Use released gems by default. To test against a local `cucumber-messages` +checkout without editing this repository, set an environment variable before +running Bundler: + +```sh +cd repos/query/ruby +CUCUMBER_MESSAGES_RUBY_PATH=../../messages/ruby bundle install +``` + +Run the Ruby checks from this directory: + +```sh +bundle exec rake +``` + +The acceptance specs load shared NDJSON fixtures from `../testdata/src` and +compare the Ruby query results with the shared expected JSON files. + +## Performance and thread-safety notes + +`Cucumber::Query::Query` keeps indexes for the whole message stream in memory. +This is the intended trade-off for formatters that need to correlate runtime +messages with source, pickle, hook, attachment, and suggestion messages. + +A query instance is mutable and is not designed for concurrent `#update` calls. +Use one query per formatter/message stream, or synchronize access externally if +you share an instance across threads. + +## Troubleshooting + +- A lookup returning `nil` usually means the related message has not been fed to + the query yet, or the input stream is incomplete. +- Retried scenarios whose `test_case_finished.will_be_retried` flag is true are + excluded from final scenario/result collections such as + `find_all_test_case_started` and `find_all_test_case_finished`. +- Status aggregation uses Cucumber's severity order: + `UNKNOWN`, `PASSED`, `SKIPPED`, `PENDING`, `UNDEFINED`, `AMBIGUOUS`, `FAILED`. +- The Ruby package is developed against shared Cucumber Query testdata; when a + new shared fixture appears, add/adjust Ruby acceptance coverage rather than + creating Ruby-only behaviour. diff --git a/ruby/Rakefile b/ruby/Rakefile new file mode 100644 index 00000000..88eea326 --- /dev/null +++ b/ruby/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' +require 'rubocop/rake_task' + +RSpec::Core::RakeTask.new(:spec) +RuboCop::RakeTask.new(:rubocop) + +task default: %i[spec rubocop] diff --git a/ruby/VERSION b/ruby/VERSION new file mode 100644 index 00000000..2bbd2b4b --- /dev/null +++ b/ruby/VERSION @@ -0,0 +1 @@ +15.0.1 diff --git a/ruby/cucumber-query.gemspec b/ruby/cucumber-query.gemspec new file mode 100644 index 00000000..382a4547 --- /dev/null +++ b/ruby/cucumber-query.gemspec @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +version = File.read(File.expand_path('VERSION', __dir__)).strip + +Gem::Specification.new do |s| + s.name = 'cucumber-query' + s.version = version + s.authors = ['Matt Wynne'] + s.description = 'Query and correlate Cucumber messages' + s.summary = "cucumber-query-#{s.version}" + s.email = 'hello@cucumber.io' + s.homepage = 'https://github.com/cucumber/query#readme' + s.platform = Gem::Platform::RUBY + s.license = 'MIT' + s.required_ruby_version = '>= 3.2' + 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/tree/main/ruby', + 'rubygems_mfa_required' => 'true', + 'source_code_uri' => 'https://github.com/cucumber/query' + } + + s.add_dependency 'cucumber-messages', '>= 32.0.0', '< 33.0.0' + + s.add_development_dependency 'rake', '~> 13.1' + s.add_development_dependency 'rspec', '~> 3.13' + s.add_development_dependency 'rubocop', '~> 1.80' + s.add_development_dependency 'rubocop-performance', '~> 1.24' + s.add_development_dependency 'rubocop-rake', '~> 0.6' + s.add_development_dependency 'rubocop-rspec', '~> 3.7' + + s.files = Dir['README.md', '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..02a072e6 --- /dev/null +++ b/ruby/lib/cucumber/query.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'query/version' +require_relative 'query/state' +require_relative 'query/gherkin_indexer' +require_relative 'query/envelope_indexer' +require_relative 'query/result_order' +require_relative 'query/naming_strategy' +require_relative 'query/finders' +require_relative 'query/query' + +module Cucumber + module Query + end +end diff --git a/ruby/lib/cucumber/query/envelope_indexer.rb b/ruby/lib/cucumber/query/envelope_indexer.rb new file mode 100644 index 00000000..57d940d7 --- /dev/null +++ b/ruby/lib/cucumber/query/envelope_indexer.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Cucumber + module Query + class EnvelopeIndexer + HANDLERS = { + meta: :update_meta, + gherkin_document: :update_gherkin_document, + pickle: :update_pickle, + hook: :update_hook, + step_definition: :update_step_definition, + test_run_started: :update_test_run_started, + test_run_hook_started: :update_test_run_hook_started, + test_run_hook_finished: :update_test_run_hook_finished, + test_case: :update_test_case, + test_case_started: :update_test_case_started, + test_step_started: :update_test_step_started, + attachment: :update_attachment, + test_step_finished: :update_test_step_finished, + test_case_finished: :update_test_case_finished, + test_run_finished: :update_test_run_finished, + suggestion: :update_suggestion, + undefined_parameter_type: :update_undefined_parameter_type + }.freeze + + def initialize(state, gherkin_indexer = GherkinIndexer.new(state)) + @state = state + @gherkin_indexer = gherkin_indexer + end + + def update(envelope) + @state.envelopes << envelope + + HANDLERS.each do |attribute, handler| + value = envelope.public_send(attribute) + public_send(handler, value) if value + end + nil + end + + def update_meta(meta) = @state.meta = meta + def update_gherkin_document(gherkin_document) = @gherkin_indexer.update(gherkin_document) + def update_hook(hook) = @state.hook_by_id[hook.id] = hook + def update_test_run_started(test_run_started) = @state.test_run_started = test_run_started + def update_test_run_finished(test_run_finished) = @state.test_run_finished = test_run_finished + + def update_pickle(pickle) + @state.pickle_by_id[pickle.id] = pickle + pickle.steps.each { |pickle_step| @state.pickle_step_by_id[pickle_step.id] = pickle_step } + end + + def update_step_definition(step_definition) + @state.step_definition_by_id[step_definition.id] = step_definition + end + + def update_test_run_hook_started(test_run_hook_started) + @state.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) + @state.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_case(test_case) + @state.test_case_by_id[test_case.id] = test_case + test_case.test_steps.each { |test_step| @state.test_step_by_id[test_step.id] = test_step } + end + + def update_test_case_started(test_case_started) + @state.test_case_started_by_id[test_case_started.id] = test_case_started + end + + def update_test_step_started(test_step_started) + @state.test_step_started_by_test_case_started_id[test_step_started.test_case_started_id] << test_step_started + end + + def update_attachment(attachment) + if attachment.test_case_started_id + @state.attachments_by_test_case_started_id[attachment.test_case_started_id] << attachment + end + return unless attachment.test_run_hook_started_id + + @state.attachments_by_test_run_hook_started_id[attachment.test_run_hook_started_id] << attachment + end + + def update_test_step_finished(test_step_finished) + @state.test_step_finished_by_test_case_started_id[test_step_finished.test_case_started_id] << test_step_finished + end + + def update_test_case_finished(test_case_finished) + @state.test_case_finished_by_test_case_started_id[test_case_finished.test_case_started_id] = test_case_finished + end + + def update_suggestion(suggestion) + @state.suggestions_by_pickle_step_id[suggestion.pickle_step_id] << suggestion + end + + def update_undefined_parameter_type(undefined_parameter_type) + @state.undefined_parameter_types << undefined_parameter_type + end + end + end +end diff --git a/ruby/lib/cucumber/query/finders.rb b/ruby/lib/cucumber/query/finders.rb new file mode 100644 index 00000000..720462f5 --- /dev/null +++ b/ruby/lib/cucumber/query/finders.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Cucumber + module Query + module Finders + def find_all_test_case_started + @state.test_case_started_by_id.values + .reject { |started| retried?(started) } + .sort_by { |started| [timestamp_ms(started.timestamp), started.id] } + end + + def find_all_test_case_finished + @state.test_case_finished_by_test_case_started_id.values + .reject(&:will_be_retried) + .sort_by { |finished| [timestamp_ms(finished.timestamp), finished.test_case_started_id] } + end + + def find_attachments_by(element) + return test_step_attachments(element) if element.respond_to?(:test_step_id) && element.test_step_id + + @state.attachments_by_test_run_hook_started_id[element.test_run_hook_started_id] + end + + def find_hook_by(item) + return find_hook_by(find_test_run_hook_started_by(item)) if item.respond_to?(:test_run_hook_started_id) + return nil unless item.respond_to?(:hook_id) && item.hook_id + + @state.hook_by_id[item.hook_id] + end + + def find_most_severe_test_step_result_by(element) + test_case_started = element.respond_to?(:test_case_started_id) ? find_test_case_started_by(element) : element + test_step_results(test_case_started).max_by { |test_step_result| status_ordinal(test_step_result.status) } + end + + def find_location_of(pickle) = pickle.location + + def find_pickle_by(element) + test_case = find_test_case_by(element) + test_case && @state.pickle_by_id[test_case.pickle_id] + end + + def find_pickle_step_by(test_step) + return nil unless test_step.respond_to?(:pickle_step_id) && test_step.pickle_step_id + + @state.pickle_step_by_id[test_step.pickle_step_id] + end + + def find_step_by(pickle_step) = @state.step_by_id[pickle_step.ast_node_ids.first] + + def find_step_definitions_by(test_step) + (test_step.step_definition_ids || []).filter_map { |id| @state.step_definition_by_id[id] } + end + + def find_suggestions_by(element) + return element.steps.flat_map { |step| find_suggestions_by(step) } if element.respond_to?(:steps) + + @state.suggestions_by_pickle_step_id[element.id] + end + + def find_unambiguous_step_definition_by(test_step) + return nil unless test_step.step_definition_ids&.length == 1 + + @state.step_definition_by_id[test_step.step_definition_ids.first] + end + + def find_test_case_by(element) + test_case_started = element.respond_to?(:test_case_started_id) ? find_test_case_started_by(element) : element + test_case_started && @state.test_case_by_id[test_case_started.test_case_id] + end + + def find_test_case_duration_by(element) + test_case_started = element.respond_to?(:test_case_started_id) ? find_test_case_started_by(element) : element + test_case_finished = element.respond_to?(:test_case_started_id) ? element : find_test_case_finished_by(element) + return nil unless test_case_started && test_case_finished + + duration_from_ms(timestamp_ms(test_case_finished.timestamp) - timestamp_ms(test_case_started.timestamp)) + end + + def find_test_case_started_by(element) = @state.test_case_started_by_id[element.test_case_started_id] + + def find_test_case_finished_by(test_case_started) + @state.test_case_finished_by_test_case_started_id[test_case_started.id] + end + + def find_test_run_hook_started_by(test_run_hook_finished) + @state.test_run_hook_started_by_id[test_run_hook_finished.test_run_hook_started_id] + end + + def find_test_run_hook_finished_by(test_run_hook_started) + @state.test_run_hook_finished_by_test_run_hook_started_id[test_run_hook_started.id] + end + + def find_test_step_by(element) = @state.test_step_by_id[element.test_step_id] + + def find_test_run_duration + return nil unless @state.test_run_started && @state.test_run_finished + + started_at = timestamp_ms(@state.test_run_started.timestamp) + finished_at = timestamp_ms(@state.test_run_finished.timestamp) + duration_from_ms(finished_at - started_at) + end + + def find_test_steps_started_by(element) + test_case_started_id = element.respond_to?(:test_case_started_id) ? element.test_case_started_id : element.id + @state.test_step_started_by_test_case_started_id[test_case_started_id] + end + + def find_test_steps_finished_by(element) + test_case_started = element.respond_to?(:test_case_started_id) ? find_test_case_started_by(element) : element + @state.test_step_finished_by_test_case_started_id[test_case_started.id] + end + + def find_test_step_finished_and_test_step_by(test_case_started) + @state.test_step_finished_by_test_case_started_id[test_case_started.id].map do |test_step_finished| + [test_step_finished, find_test_step_by(test_step_finished)] + end + end + + def find_lineage_by(element) + pickle = element.respond_to?(:ast_node_ids) ? element : find_pickle_by(element) + return nil unless pickle + + ast_node_id = pickle.ast_node_ids&.last + return nil unless ast_node_id + + @state.lineage_by_id[ast_node_id] + end + end + end +end diff --git a/ruby/lib/cucumber/query/gherkin_indexer.rb b/ruby/lib/cucumber/query/gherkin_indexer.rb new file mode 100644 index 00000000..8c9a52a3 --- /dev/null +++ b/ruby/lib/cucumber/query/gherkin_indexer.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Cucumber + module Query + class GherkinIndexer + def initialize(state) + @state = state + end + + def update(gherkin_document) + return unless gherkin_document.feature + + update_feature(gherkin_document.feature, { gherkin_document: gherkin_document }) + end + + private + + def update_feature(feature, lineage) + feature.children.each do |feature_child| + lineage = update_background(feature_child.background, lineage, :background) if feature_child.background + update_scenario(feature_child.scenario, lineage.merge(feature: feature)) if feature_child.scenario + update_rule(feature_child.rule, lineage.merge(feature: feature)) if feature_child.rule + end + end + + def update_rule(rule, lineage) + rule.children.each do |rule_child| + lineage = update_background(rule_child.background, lineage, :rule_background) if rule_child.background + update_scenario(rule_child.scenario, lineage.merge(rule: rule)) if rule_child.scenario + end + end + + def update_background(background, lineage, key) + update_steps(background.steps) + lineage.merge(key => background) + end + + def update_scenario(scenario, lineage) + @state.lineage_by_id[scenario.id] = lineage.merge(scenario: scenario) + scenario.examples.each_with_index do |examples, examples_index| + update_examples_lineage(scenario, lineage, examples, examples_index) + end + update_steps(scenario.steps) + end + + def update_examples_lineage(scenario, lineage, examples, examples_index) + examples_lineage = lineage.merge(scenario: scenario, examples: examples, examples_index: examples_index) + @state.lineage_by_id[examples.id] = examples_lineage + examples.table_body.each_with_index do |example, example_index| + @state.lineage_by_id[example.id] = examples_lineage.merge(example: example, example_index: example_index) + end + end + + def update_steps(steps) + steps.each { |step| @state.step_by_id[step.id] = step } + end + end + end +end diff --git a/ruby/lib/cucumber/query/naming_strategy.rb b/ruby/lib/cucumber/query/naming_strategy.rb new file mode 100644 index 00000000..c5421a87 --- /dev/null +++ b/ruby/lib/cucumber/query/naming_strategy.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Cucumber + module Query + NAMING_STRATEGY_LENGTH_LONG = :long + NAMING_STRATEGY_LENGTH_SHORT = :short + + NAMING_STRATEGY_FEATURE_NAME_INCLUDE = :include + NAMING_STRATEGY_FEATURE_NAME_EXCLUDE = :exclude + + NAMING_STRATEGY_EXAMPLE_NAME_NUMBER = :number + NAMING_STRATEGY_EXAMPLE_NAME_PICKLE = :pickle + NAMING_STRATEGY_EXAMPLE_NAME_NUMBER_AND_PICKLE_IF_PARAMETERIZED = :number_and_pickle_if_parameterized + + # Reduces a Gherkin lineage and pickle into the test name used by polyglot formatters. + class BuiltinNamingStrategy + def initialize(length:, feature_name: NAMING_STRATEGY_FEATURE_NAME_INCLUDE, + example_name: NAMING_STRATEGY_EXAMPLE_NAME_NUMBER_AND_PICKLE_IF_PARAMETERIZED) + @length = length + @feature_name = feature_name + @example_name = example_name + end + + def reduce(lineage, pickle) + filtered_parts = parts(lineage, pickle).compact.reject(&:empty?) + return filtered_parts.last if @length == NAMING_STRATEGY_LENGTH_SHORT + + filtered_parts.join(' - ') + end + + private + + def parts(lineage, pickle) + [ + feature_name(lineage), + lineage[:rule]&.name, + lineage[:scenario]&.name || pickle.name, + lineage[:examples]&.name, + lineage[:example] && example_name(lineage, pickle) + ] + end + + def feature_name(lineage) + lineage[:feature]&.name if @feature_name == NAMING_STRATEGY_FEATURE_NAME_INCLUDE + end + + def example_name(lineage, pickle) + example_number = example_number(lineage) + return example_number if @example_name == NAMING_STRATEGY_EXAMPLE_NAME_NUMBER + return pickle.name if @example_name == NAMING_STRATEGY_EXAMPLE_NAME_PICKLE + return parameterized_example_name(lineage, pickle, example_number) if parameterized_example_strategy? + + raise ArgumentError, "unknown example naming strategy: #{@example_name.inspect}" + end + + def example_number(lineage) = "##{lineage.fetch(:examples_index, 0) + 1}.#{lineage.fetch(:example_index, 0) + 1}" + + def parameterized_example_name(lineage, pickle, example_number) + lineage[:scenario]&.name == pickle.name ? example_number : "#{example_number}: #{pickle.name}" + end + + def parameterized_example_strategy? + @example_name == NAMING_STRATEGY_EXAMPLE_NAME_NUMBER_AND_PICKLE_IF_PARAMETERIZED + end + end + + module_function + + def naming_strategy(length, feature_name = NAMING_STRATEGY_FEATURE_NAME_INCLUDE, + example_name = NAMING_STRATEGY_EXAMPLE_NAME_NUMBER_AND_PICKLE_IF_PARAMETERIZED) + BuiltinNamingStrategy.new(length:, feature_name:, example_name:) + end + end +end diff --git a/ruby/lib/cucumber/query/query.rb b/ruby/lib/cucumber/query/query.rb new file mode 100644 index 00000000..293501db --- /dev/null +++ b/ruby/lib/cucumber/query/query.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Cucumber + module Query + class Query + include Finders + + STATUS_ORDINAL = { + 'UNKNOWN' => 0, + 'PASSED' => 1, + 'SKIPPED' => 2, + 'PENDING' => 3, + 'UNDEFINED' => 4, + 'AMBIGUOUS' => 5, + 'FAILED' => 6 + }.freeze + + def initialize(state = State.new, indexer = EnvelopeIndexer.new(state)) + @state = state + @indexer = indexer + end + + def envelopes = @state.envelopes + + def update(envelope) = @indexer.update(envelope) + + def count_most_severe_test_step_result_status + STATUS_ORDINAL.keys.to_h { |status| [status, count_status(status)] } + end + + def count_test_cases_started = find_all_test_case_started.length + + def find_all_pickles = @state.pickle_by_id.values + def find_all_pickle_steps = @state.pickle_step_by_id.values + def find_all_step_definitions = @state.step_definition_by_id.values + def find_all_test_cases = @state.test_case_by_id.values + def find_all_test_steps = @state.test_step_by_id.values + def find_all_test_step_started = @state.test_step_started_by_test_case_started_id.values.flatten + def find_all_test_step_finished = @state.test_step_finished_by_test_case_started_id.values.flatten + def find_all_test_run_hook_started = @state.test_run_hook_started_by_id.values + def find_all_test_run_hook_finished = @state.test_run_hook_finished_by_test_run_hook_started_id.values + def find_all_undefined_parameter_types = @state.undefined_parameter_types.dup + def find_meta = @state.meta + def find_test_run_finished = @state.test_run_finished + def find_test_run_started = @state.test_run_started + + def find_all_test_case_started_order_by(find_order_by, order) + ResultOrder.new(find_order_by, order).sort(self, find_all_test_case_started) + end + + def find_all_test_case_finished_order_by(find_order_by, order) + ResultOrder.new(find_order_by, order).sort(self, find_all_test_case_finished) + end + + private + + def count_status(status) + find_all_test_case_started.count do |test_case_started| + find_most_severe_test_step_result_by(test_case_started)&.status == status + end + end + + def retried?(test_case_started) + @state.test_case_finished_by_test_case_started_id[test_case_started.id]&.will_be_retried + end + + def test_step_attachments(element) + @state.attachments_by_test_case_started_id[element.test_case_started_id] + .select { |attachment| attachment.test_step_id == element.test_step_id } + end + + def test_step_results(test_case_started) + find_test_step_finished_and_test_step_by(test_case_started) + .map { |test_step_finished, _test_step| test_step_finished.test_step_result } + end + + def timestamp_ms(timestamp) + (timestamp.seconds * 1_000) + timestamp.nanos.fdiv(1_000_000) + end + + def duration_from_ms(milliseconds) + seconds = milliseconds.div(1_000) + nanos = ((milliseconds - (seconds * 1_000)) * 1_000_000).round + Cucumber::Messages::Duration.new(seconds: seconds, nanos: nanos) + end + + def status_ordinal(status) = STATUS_ORDINAL.fetch(status) + end + end +end diff --git a/ruby/lib/cucumber/query/result_order.rb b/ruby/lib/cucumber/query/result_order.rb new file mode 100644 index 00000000..f21db66c --- /dev/null +++ b/ruby/lib/cucumber/query/result_order.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Cucumber + module Query + class ResultOrder + def initialize(find_order_by, order) + @find_order_by = find_order_by + @order = order + end + + def sort(query, items) + items.map { |item| [item, @find_order_by.call(query, item)] } + .sort { |left, right| compare(left.last, right.last) } + .map(&:first) + end + + private + + def compare(left, right) + return 0 if left.nil? && right.nil? + return 1 if left.nil? + return -1 if right.nil? + + @order.call(left, right) + end + end + end +end diff --git a/ruby/lib/cucumber/query/state.rb b/ruby/lib/cucumber/query/state.rb new file mode 100644 index 00000000..31425b8d --- /dev/null +++ b/ruby/lib/cucumber/query/state.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Cucumber + module Query + class State + attr_accessor :meta, :test_run_started, :test_run_finished + attr_reader :envelopes, :test_case_started_by_id, :lineage_by_id, :step_by_id, + :pickle_by_id, :pickle_step_by_id, :hook_by_id, :step_definition_by_id, + :test_case_by_id, :test_step_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_started_by_test_case_started_id, + :test_step_finished_by_test_case_started_id, + :attachments_by_test_case_started_id, + :attachments_by_test_run_hook_started_id, + :suggestions_by_pickle_step_id, :undefined_parameter_types + + def initialize + @envelopes = [] + initialize_identity_indexes + initialize_collection_indexes + @undefined_parameter_types = [] + end + + private + + def initialize_identity_indexes + identity_index_names.each { |name| instance_variable_set("@#{name}", {}) } + end + + def identity_index_names + %i[ + test_case_started_by_id lineage_by_id step_by_id pickle_by_id pickle_step_by_id + hook_by_id step_definition_by_id test_case_by_id test_step_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 + ] + end + + def initialize_collection_indexes + @test_step_started_by_test_case_started_id = hash_of_arrays + @test_step_finished_by_test_case_started_id = hash_of_arrays + @attachments_by_test_case_started_id = hash_of_arrays + @attachments_by_test_run_hook_started_id = hash_of_arrays + @suggestions_by_pickle_step_id = hash_of_arrays + end + + def hash_of_arrays + Hash.new { |hash, key| hash[key] = [] } + end + end + end +end diff --git a/ruby/lib/cucumber/query/version.rb b/ruby/lib/cucumber/query/version.rb new file mode 100644 index 00000000..4f5e4008 --- /dev/null +++ b/ruby/lib/cucumber/query/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Cucumber + module Query + VERSION = File.read(File.expand_path('../../../VERSION', __dir__)).strip + end +end diff --git a/ruby/spec/acceptance/query_testdata_spec.rb b/ruby/spec/acceptance/query_testdata_spec.rb new file mode 100644 index 00000000..ab64b8fb --- /dev/null +++ b/ruby/spec/acceptance/query_testdata_spec.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/DescribeClass, Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration + +require 'cucumber/messages/helpers/ndjson_to_message_enumerator' + +RSpec.describe 'shared cucumber-query testdata' do + TESTDATA_DIR = File.expand_path('../../../testdata/src', __dir__) + + def load_query(suite) + query = Cucumber::Query::Query.new + File.open(File.join(TESTDATA_DIR, "#{suite}.ndjson"), 'r') do |io| + Cucumber::Messages::Helpers::NdjsonToMessageEnumerator.new(io).each do |envelope| + query.update(envelope) + end + end + query + end + + def json(value) + JSON.parse(JSON.generate(value)) + end + + EXAMPLE_NAME = lambda do |lineage| + "##{lineage[:examples_index] + 1}.#{lineage[:example_index] + 1}" + end + + LINEAGE_NAME = lambda do |lineage| + parts = [] + parts << lineage[:feature]&.name + parts << lineage[:rule]&.name + parts << lineage[:scenario]&.name + parts << lineage[:examples]&.name + parts << EXAMPLE_NAME.call(lineage) if lineage[:example] + parts.compact.reject(&:empty?).join(' - ') + end + + REVERSE_PICKLE_COMPARATOR = lambda do |left, right| + [right.uri, right.location.line, right.location.column] <=> [left.uri, left.location.line, left.location.column] + end + + collection_cases = { + 'findAllTestCaseStartedOrderBy' => lambda { |query| + query.find_all_test_case_started_order_by( + ->(candidate_query, test_case_started) { candidate_query.find_pickle_by(test_case_started) }, + REVERSE_PICKLE_COMPARATOR + ).map(&:id) + }, + 'findAllTestCaseFinishedOrderBy' => lambda { |query| + query.find_all_test_case_finished_order_by( + ->(candidate_query, test_case_finished) { candidate_query.find_pickle_by(test_case_finished) }, + REVERSE_PICKLE_COMPARATOR + ).map(&:test_case_started_id) + }, + 'findAllTestCases' => ->(query) { query.find_all_test_cases.length }, + 'findAllTestStepsStarted' => ->(query) { query.find_all_test_step_started.length }, + 'findAllTestStepsFinished' => ->(query) { query.find_all_test_step_finished.length }, + 'findAllUndefinedParameterTypes' => lambda { |query| + query.find_all_undefined_parameter_types.map do |undefined_parameter_type| + [undefined_parameter_type.name, undefined_parameter_type.expression] + end + }, + 'findTestRunStarted' => lambda(&:find_test_run_started), + 'findTestRunFinished' => lambda(&:find_test_run_finished) + } + + relationship_cases = { + 'findLineageBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.filter_map do |item| + lineage = query.find_lineage_by(item) + LINEAGE_NAME.call(lineage) if lineage + end, + testCaseFinished: query.find_all_test_case_finished.filter_map do |item| + lineage = query.find_lineage_by(item) + LINEAGE_NAME.call(lineage) if lineage + end, + pickle: query.find_all_pickles.filter_map do |item| + lineage = query.find_lineage_by(item) + LINEAGE_NAME.call(lineage) if lineage + end + } + }, + 'findLocationOf' => ->(query) { query.find_all_pickles.filter_map { |item| query.find_location_of(item) } }, + 'findPickleBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.map { |item| query.find_pickle_by(item)&.name }, + testCaseFinished: query.find_all_test_case_finished.map { |item| query.find_pickle_by(item)&.name }, + testStepStarted: query.find_all_test_step_started.map { |item| query.find_pickle_by(item)&.name }, + testStepFinished: query.find_all_test_step_finished.map { |item| query.find_pickle_by(item)&.name } + } + }, + 'findPickleStepBy' => lambda { |query| + query.find_all_test_steps.filter_map { |item| query.find_pickle_step_by(item)&.text } + }, + 'findStepBy' => ->(query) { query.find_all_pickle_steps.map { |item| query.find_step_by(item)&.text } }, + 'findStepDefinitionsBy' => lambda { |query| + query.find_all_test_steps.map { |item| query.find_step_definitions_by(item).map(&:id) } + }, + 'findSuggestionsBy' => lambda { |query| + { + pickleStep: query.find_all_pickle_steps.flat_map { |item| query.find_suggestions_by(item) }.map(&:id), + pickle: query.find_all_pickles.flat_map { |item| query.find_suggestions_by(item) }.map(&:id) + } + }, + 'findUnambiguousStepDefinitionBy' => lambda { |query| + query.find_all_test_steps.filter_map { |item| query.find_unambiguous_step_definition_by(item)&.id } + }, + 'findTestCaseStartedBy' => lambda { |query| + { + testCaseFinished: query.find_all_test_case_finished.map { |item| query.find_test_case_started_by(item)&.id }, + testStepStarted: query.find_all_test_step_started.map { |item| query.find_test_case_started_by(item)&.id }, + testStepFinished: query.find_all_test_step_finished.map { |item| query.find_test_case_started_by(item)&.id } + } + }, + 'findTestCaseBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.map { |item| query.find_test_case_by(item)&.id }, + testCaseFinished: query.find_all_test_case_finished.map { |item| query.find_test_case_by(item)&.id }, + testStepStarted: query.find_all_test_step_started.map { |item| query.find_test_case_by(item)&.id }, + testStepFinished: query.find_all_test_step_finished.map { |item| query.find_test_case_by(item)&.id } + } + }, + 'findTestCaseFinishedBy' => lambda { |query| + query.find_all_test_case_started.map { |item| query.find_test_case_finished_by(item)&.test_case_started_id } + }, + 'findTestStepBy' => lambda { |query| + query.find_all_test_case_started.flat_map { |item| query.find_test_steps_started_by(item) } + .map { |item| query.find_test_step_by(item)&.id } + }, + 'findTestStepsStartedBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.map do |item| + query.find_test_steps_started_by(item).map(&:test_step_id) + end, + testCaseFinished: query.find_all_test_case_finished.map do |item| + query.find_test_steps_started_by(item).map(&:test_step_id) + end + } + }, + 'findTestStepsFinishedBy' => lambda { |query| + query.find_all_test_case_started.map { |item| query.find_test_steps_finished_by(item).map(&:test_step_id) } + }, + 'findTestStepByTestStepFinished' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.flat_map { |item| query.find_test_steps_finished_by(item) } + .map { |item| query.find_test_step_by(item)&.id }, + testCaseFinished: query.find_all_test_case_finished.flat_map { |item| query.find_test_steps_finished_by(item) } + .map { |item| query.find_test_step_by(item)&.id } + } + }, + 'findTestStepFinishedAndTestStepBy' => lambda { |query| + query.find_all_test_case_started.flat_map { |item| query.find_test_step_finished_and_test_step_by(item) } + .map { |test_step_finished, test_step| [test_step_finished.test_step_id, test_step.id] } + } + } + + attachment_and_severity_cases = { + 'findAttachmentsBy' => lambda { |query| + test_step_attachments = query.find_all_test_case_started + .flat_map { |started| query.find_test_steps_finished_by(started) } + .flat_map { |finished| query.find_attachments_by(finished) } + test_run_hook_attachments = query.find_all_test_run_hook_finished + .flat_map { |finished| query.find_attachments_by(finished) } + + { + testStepFinished: test_step_attachments.map do |attachment| + [ + attachment.test_step_id, + attachment.test_case_started_id, + attachment.media_type, + attachment.content_encoding + ] + end, + testRunHookFinished: test_run_hook_attachments.map do |attachment| + [ + attachment.test_run_hook_started_id, + attachment.media_type, + attachment.content_encoding + ] + end + } + }, + 'findMostSevereTestStepResultBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.filter_map do |test_case_started| + query.find_most_severe_test_step_result_by(test_case_started)&.status + end, + testCaseFinished: query.find_all_test_case_finished.filter_map do |test_case_finished| + query.find_most_severe_test_step_result_by(test_case_finished)&.status + end + } + } + } + + cases = { + 'minimal' => { + 'countMostSevereTestStepResultStatus' => lambda(&:count_most_severe_test_step_result_status), + 'countTestCasesStarted' => lambda(&:count_test_cases_started), + 'findAllPickles' => ->(query) { query.find_all_pickles.length }, + 'findAllPickleSteps' => ->(query) { query.find_all_pickle_steps.length }, + 'findAllStepDefinitions' => ->(query) { query.find_all_step_definitions.length }, + 'findAllTestCaseStarted' => ->(query) { query.find_all_test_case_started.length }, + 'findAllTestCaseFinished' => ->(query) { query.find_all_test_case_finished.length }, + 'findAllTestSteps' => ->(query) { query.find_all_test_steps.length }, + 'findMeta' => ->(query) { query.find_meta&.implementation&.name }, + 'findTestCaseDurationBy' => lambda { |query| + { + testCaseStarted: query.find_all_test_case_started.map { |item| query.find_test_case_duration_by(item)&.to_h }, + testCaseFinished: query.find_all_test_case_finished.map do |item| + query.find_test_case_duration_by(item)&.to_h + end + } + }, + 'findTestRunDuration' => ->(query) { query.find_test_run_duration&.to_h } + }, + 'hooks' => { + 'findAllTestCaseStarted' => ->(query) { query.find_all_test_case_started.length }, + 'findAllTestSteps' => ->(query) { query.find_all_test_steps.length }, + 'findHookBy' => lambda { |query| + { + testStep: query.find_all_test_steps.filter_map { |item| query.find_hook_by(item)&.id }, + testRunHookStarted: query.find_all_test_run_hook_started.filter_map { |item| query.find_hook_by(item)&.id }, + testRunHookFinished: query.find_all_test_run_hook_finished.filter_map { |item| query.find_hook_by(item)&.id } + } + } + }, + 'global-hooks' => { + 'findAllTestRunHookStarted' => ->(query) { query.find_all_test_run_hook_started.length }, + 'findAllTestRunHookFinished' => ->(query) { query.find_all_test_run_hook_finished.length }, + 'findTestRunHookStartedBy' => lambda { |query| + query.find_all_test_run_hook_finished.map { |item| query.find_test_run_hook_started_by(item)&.id } + }, + 'findTestRunHookFinishedBy' => lambda { |query| + query.find_all_test_run_hook_started.map do |item| + query.find_test_run_hook_finished_by(item)&.test_run_hook_started_id + end + } + } + } + + %w[ + attachments + empty + global-hooks + global-hooks-attachments + hooks + minimal + rules + examples-tables + unknown-parameter-type + ].each do |suite| + cases[suite] ||= {} + cases[suite].merge!(collection_cases, relationship_cases, attachment_and_severity_cases) + end + + naming_cases = { + 'long' => Cucumber::Query.naming_strategy(Cucumber::Query::NAMING_STRATEGY_LENGTH_LONG), + 'short' => Cucumber::Query.naming_strategy(Cucumber::Query::NAMING_STRATEGY_LENGTH_SHORT), + 'long-exclude-feature-name' => Cucumber::Query.naming_strategy( + Cucumber::Query::NAMING_STRATEGY_LENGTH_LONG, + Cucumber::Query::NAMING_STRATEGY_FEATURE_NAME_EXCLUDE + ), + 'long-with-pickle-name' => Cucumber::Query.naming_strategy( + Cucumber::Query::NAMING_STRATEGY_LENGTH_LONG, + Cucumber::Query::NAMING_STRATEGY_FEATURE_NAME_INCLUDE, + Cucumber::Query::NAMING_STRATEGY_EXAMPLE_NAME_PICKLE + ), + 'long-with-pickle-name-if-parameterized' => Cucumber::Query.naming_strategy( + Cucumber::Query::NAMING_STRATEGY_LENGTH_LONG, + Cucumber::Query::NAMING_STRATEGY_FEATURE_NAME_INCLUDE, + Cucumber::Query::NAMING_STRATEGY_EXAMPLE_NAME_NUMBER_AND_PICKLE_IF_PARAMETERIZED + ) + } + + cases.each do |suite, suite_cases| + suite_cases.each do |query_name, operation| + it "matches #{suite}.#{query_name}.results.json" do + expected = JSON.parse(File.read(File.join(TESTDATA_DIR, "#{suite}.#{query_name}.results.json"))) + expect(json(operation.call(load_query(suite)))).to eq(expected) + end + end + end + + %w[minimal rules examples-tables].each do |suite| + naming_cases.each do |strategy_name, strategy| + it "matches #{suite}.naming-strategy.#{strategy_name}.txt" do + query = load_query(suite) + names = query.find_all_pickles.map { |pickle| strategy.reduce(query.find_lineage_by(pickle), pickle) } + expected = naming_expectation(suite, strategy_name) + + expect(names).to eq(expected) + end + end + end + + def naming_expectation(suite, strategy_name) + File.readlines(File.join(TESTDATA_DIR, "#{suite}.naming-strategy.#{strategy_name}.txt"), chomp: true) + end +end + +# rubocop:enable RSpec/DescribeClass, Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 00000000..967b0217 --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) +require 'cucumber/query' + +RSpec.configure do |config| + config.disable_monkey_patching! + config.example_status_persistence_file_path = '.rspec_status' + config.expect_with :rspec do |expectations| + expectations.syntax = :expect + end +end diff --git a/ruby/spec/unit/query_spec.rb b/ruby/spec/unit/query_spec.rb new file mode 100644 index 00000000..aa67ae05 --- /dev/null +++ b/ruby/spec/unit/query_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe Cucumber::Query::Query do + it 'accepts message envelopes via #update' do + envelope = Cucumber::Messages::Envelope.new + query = described_class.new + + expect(query.update(envelope)).to be_nil + expect(query.envelopes).to eq([envelope]) + end + + describe '#find_lineage_by' do + it 'returns nil when the related pickle cannot be found' do + test_case_started = Struct.new(:test_case_id).new('missing-test-case') + query = described_class.new + + expect(query.find_lineage_by(test_case_started)).to be_nil + end + + it 'returns nil when the pickle has no AST node id' do + pickle = Struct.new(:ast_node_ids).new([]) + query = described_class.new + + expect(query.find_lineage_by(pickle)).to be_nil + end + end +end