diff --git a/.gitignore b/.gitignore index c8e93d3..71a4249 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,5 @@ desktop.ini /config/application.yml example-1 +monorepo +specs diff --git a/Makefile b/Makefile index 19b3c0c..135068a 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: install build test setup-monorepo update-monorepo + install: bundle install @@ -6,3 +8,15 @@ build: test: bundle exec rspec spec/ + +setup-monorepo: + mkdir -p monorepo + if [ ! -d "monorepo/.git" ]; then \ + git clone git@github.com:featurevisor/featurevisor.git monorepo; \ + else \ + (cd monorepo && git fetch origin main && git checkout main && git pull origin main); \ + fi + (cd monorepo && make install && make build) + +update-monorepo: + (cd monorepo && git pull origin main) diff --git a/README.md b/README.md index 1e7b393..f3e5a21 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [Close](#close) - [CLI usage](#cli-usage) - [Test](#test) + - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1) - [Benchmark](#benchmark) - [Assess distribution](#assess-distribution) - [Development](#development) @@ -383,7 +384,7 @@ require 'json' def update_datafile(f, datafile_url) loop do sleep(5 * 60) # 5 minutes - + begin response = Net::HTTP.get_response(URI(datafile_url)) datafile_content = JSON.parse(response.body) @@ -683,10 +684,27 @@ Additional options that are available: ```bash $ bundle exec featurevisor test \ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ - --quiet|verbose \ + --quiet|--verbose \ --onlyFailures \ --keyPattern="myFeatureKey" \ - --assertionPattern="#1" + --assertionPattern="#1" \ + --with-scopes \ + --with-tags +``` + +`--with-scopes` and `--with-tags` make the Ruby test runner build scoped/tagged datafiles in memory (via `npx featurevisor build --json`) and evaluate matching assertions against those exact datafiles. + +If an assertion references `scope` and `--with-scopes` is not provided, the runner still evaluates the assertion by merging that scope's configured context into the assertion context (without building scoped datafiles). + +For compatibility, camelCase aliases are also supported: `--withScopes` and `--withTags`. + +### Test against local monorepo's example-1 + +```bash +$ cd /absolute/path/to/featurevisor-ruby +$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 +$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-scopes +$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-tags ``` ### Benchmark diff --git a/bin/cli.rb b/bin/cli.rb index 5d3ad4c..1c21609 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -7,7 +7,7 @@ class Options attr_accessor :command, :assertion_pattern, :context, :environment, :feature, :key_pattern, :n, :only_failures, :quiet, :variable, :variation, :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path, - :populate_uuid + :populate_uuid, :with_scopes, :with_tags def initialize @n = 1000 @@ -86,6 +86,14 @@ def self.parse(args) options.schema_version = v end + opts.on("--with-scopes", "--withScopes", "Test scoped assertions against scoped datafiles") do + options.with_scopes = true + end + + opts.on("--with-tags", "--withTags", "Test tagged assertions against tagged datafiles") do + options.with_tags = true + end + opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v| options.project_directory_path = v end diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 66df962..6d96d79 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -1,6 +1,7 @@ require "json" require "find" require "open3" +require "shellwords" module FeaturevisorCLI module Commands @@ -19,7 +20,7 @@ def run # Get project configuration config = get_config - environments = config[:environments] || [] + environments = get_environments(config) segments_by_key = get_segments # Use CLI schemaVersion option or fallback to config @@ -28,8 +29,8 @@ def run schema_version = config[:schemaVersion] end - # Build datafiles for all environments - datafiles_by_environment = build_datafiles(environments, schema_version, @options.inflate) + # Build datafiles for all environments (+ scoped/tagged variants) + datafiles_by_key = build_datafiles(config, environments, schema_version, @options.inflate) puts "" @@ -42,18 +43,15 @@ def run return end - # Create SDK instances for each environment - sdk_instances_by_environment = create_sdk_instances(environments, datafiles_by_environment, level) - # Run tests - run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level) + run_tests(tests, datafiles_by_key, segments_by_key, level, config) end private def get_config puts "Getting config..." - command = "(cd #{@project_path} && npx featurevisor config --json)" + command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor config --json)" config_output = execute_command(command) begin @@ -67,7 +65,7 @@ def get_config def get_segments puts "Getting segments..." - command = "(cd #{@project_path} && npx featurevisor list --segments --json)" + command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --segments --json)" segments_output = execute_command(command) begin @@ -86,40 +84,111 @@ def get_segments end end - def build_datafiles(environments, schema_version, inflate) - datafiles_by_environment = {} + def get_environments(config) + environments = config[:environments] + + return [false] if environments == false + return environments if environments.is_a?(Array) && !environments.empty? + + [false] + end + + def base_datafile_key(environment) + environment == false ? false : environment + end + + def scoped_datafile_key(environment, scope_name) + if environment == false + "scope-#{scope_name}" + else + "#{environment}-scope-#{scope_name}" + end + end + + def tagged_datafile_key(environment, tag) + if environment == false + "tag-#{tag}" + else + "#{environment}-tag-#{tag}" + end + end + + def build_datafiles(config, environments, schema_version, inflate) + datafiles_by_key = {} environments.each do |environment| - puts "Building datafile for environment: #{environment}..." + environment_label = environment == false ? "default (no environment)" : environment + puts "Building datafile for environment: #{environment_label}..." - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] + datafiles_by_key[base_datafile_key(environment)] = build_single_datafile( + environment: environment, + schema_version: schema_version, + inflate: inflate + ) - if schema_version && !schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--json"] + if @options.with_scopes && config[:scopes].is_a?(Array) + config[:scopes].each do |scope| + next unless scope[:name] + + puts "Building scoped datafile for scope: #{scope[:name]}..." + datafiles_by_key[scoped_datafile_key(environment, scope[:name])] = build_single_datafile( + environment: environment, + schema_version: schema_version, + inflate: inflate, + scope: scope[:name] + ) + end end - if inflate && inflate > 0 - if schema_version && !schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{schema_version}", "--inflate=#{inflate}", "--json"] - else - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{inflate}", "--json"] + if @options.with_tags && config[:tags].is_a?(Array) + config[:tags].each do |tag| + puts "Building tagged datafile for tag: #{tag}..." + datafiles_by_key[tagged_datafile_key(environment, tag)] = build_single_datafile( + environment: environment, + schema_version: schema_version, + inflate: inflate, + tag: tag + ) end end + end - command = command_parts.join(" ") - datafile_output = execute_command(command) + datafiles_by_key + end - begin - datafile = JSON.parse(datafile_output, symbolize_names: true) - datafiles_by_environment[environment] = datafile - rescue JSON::ParserError => e - puts "Error: Failed to parse datafile JSON for #{environment}: #{e.message}" - puts "Command output: #{datafile_output}" - exit 1 - end + def build_single_datafile(environment:, schema_version:, inflate:, scope: nil, tag: nil) + command_parts = ["npx", "featurevisor", "build", "--json"] + + if environment != false && !environment.nil? + command_parts << "--environment=#{Shellwords.escape(environment.to_s)}" + end + + if schema_version && !schema_version.empty? + command_parts << "--schemaVersion=#{Shellwords.escape(schema_version.to_s)}" + end + + if inflate && inflate > 0 + command_parts << "--inflate=#{inflate}" + end + + if scope + command_parts << "--scope=#{Shellwords.escape(scope.to_s)}" + end + + if tag + command_parts << "--tag=#{Shellwords.escape(tag.to_s)}" end - datafiles_by_environment + command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" + datafile_output = execute_command(command) + + begin + JSON.parse(datafile_output, symbolize_names: true) + rescue JSON::ParserError => e + puts "Error: Failed to parse datafile JSON: #{e.message}" + puts "Command output: #{datafile_output}" + exit 1 + end end def get_logger_level @@ -133,17 +202,17 @@ def get_logger_level end def get_tests - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"] + command_parts = ["npx", "featurevisor", "list", "--tests", "--applyMatrix", "--json"] if @options.key_pattern && !@options.key_pattern.empty? - command_parts << "--keyPattern=#{@options.key_pattern}" + command_parts << "--keyPattern=#{Shellwords.escape(@options.key_pattern)}" end if @options.assertion_pattern && !@options.assertion_pattern.empty? - command_parts << "--assertionPattern=#{@options.assertion_pattern}" + command_parts << "--assertionPattern=#{Shellwords.escape(@options.assertion_pattern)}" end - command = command_parts.join(" ") + command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" tests_output = execute_command(command) begin @@ -155,31 +224,58 @@ def get_tests end end - def create_sdk_instances(environments, datafiles_by_environment, level) - sdk_instances_by_environment = {} + def get_scope_context(config, scope_name) + return {} unless scope_name && config[:scopes].is_a?(Array) - environments.each do |environment| - datafile = datafiles_by_environment[environment] - - # Create SDK instance - instance = Featurevisor.create_instance( - datafile: datafile, - log_level: level, - hooks: [ - { - name: "tester-hook", - bucket_value: ->(options) { options.bucket_value } - } - ] - ) + scope = config[:scopes].find { |s| s[:name] == scope_name } + return {} unless scope && scope[:context].is_a?(Hash) + + parse_context(scope[:context]) + end + + def resolve_datafile_for_assertion(assertion, datafiles_by_key) + environment = assertion.key?(:environment) ? assertion[:environment] : false + environment = false if environment.nil? + + scoped_key = assertion[:scope] ? scoped_datafile_key(environment, assertion[:scope]) : nil + tagged_key = assertion[:tag] ? tagged_datafile_key(environment, assertion[:tag]) : nil + base_key = base_datafile_key(environment) + + if scoped_key && datafiles_by_key.key?(scoped_key) + return datafiles_by_key[scoped_key] + end - sdk_instances_by_environment[environment] = instance + if tagged_key && datafiles_by_key.key?(tagged_key) + return datafiles_by_key[tagged_key] end - sdk_instances_by_environment + datafiles_by_key[base_key] end - def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, segments_by_key, level) + def create_tester_instance(datafile, level, assertion) + sticky = parse_sticky(assertion[:sticky]) + + Featurevisor.create_instance( + datafile: datafile, + sticky: sticky, + log_level: level, + hooks: [ + { + name: "tester-hook", + bucket_value: ->(options) do + at = assertion[:at] + if at.is_a?(Numeric) + (at * 1000).to_i + else + options.bucket_value + end + end + } + ] + ) + end + + def run_tests(tests, datafiles_by_key, segments_by_key, level, config) passed_tests_count = 0 failed_tests_count = 0 passed_assertions_count = 0 @@ -197,43 +293,33 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg test_result = nil if test[:feature] - environment = assertion[:environment] - instance = sdk_instances_by_environment[environment] - - # Show datafile if requested - if @options.show_datafile - datafile = datafiles_by_environment[environment] - puts "" - puts JSON.pretty_generate(datafile) - puts "" + datafile = resolve_datafile_for_assertion(assertion, datafiles_by_key) + if datafile.nil? + test_result = { + has_error: true, + errors: " ✘ no datafile found for assertion scope/tag/environment combination\n", + duration: 0 + } end - # If "at" parameter is provided, create a new instance with the specific hook - if assertion[:at] - datafile = datafiles_by_environment[environment] - - instance = Featurevisor.create_instance( - datafile: datafile, - log_level: level, - hooks: [ - { - name: "tester-hook", - bucket_value: ->(options) do - # Match JavaScript implementation: assertion.at * (MAX_BUCKETED_NUMBER / 100) - # MAX_BUCKETED_NUMBER is 100000, so this becomes assertion.at * 1000 - at = assertion[:at] - if at.is_a?(Numeric) - (at * 1000).to_i - else - options.bucket_value - end - end - } - ] - ) - end + if datafile + instance = create_tester_instance(datafile, level, assertion) + scope_context = {} + + if assertion[:scope] && !@options.with_scopes + # If not using scoped datafiles, mimic JS behavior by merging scope context. + scope_context = get_scope_context(config, assertion[:scope]) + end + + # Show datafile if requested + if @options.show_datafile + puts "" + puts JSON.pretty_generate(datafile) + puts "" + end - test_result = run_test_feature(assertion, test[:feature], instance, level) + test_result = run_test_feature(assertion, test[:feature], instance, level, scope_context) + end elsif test[:segment] segment_key = test[:segment] segment = segments_by_key[segment_key] @@ -280,8 +366,9 @@ def run_tests(tests, sdk_instances_by_environment, datafiles_by_environment, seg end end - def run_test_feature(assertion, feature_key, instance, level) + def run_test_feature(assertion, feature_key, instance, level, scope_context = {}) context = parse_context(assertion[:context]) + context = { **scope_context, **context } if scope_context && !scope_context.empty? sticky = parse_sticky(assertion[:sticky]) # Set context and sticky for this assertion @@ -325,7 +412,7 @@ def run_test_feature(assertion, feature_key, instance, level) expected_variables = assertion[:expectedVariables] expected_variables.each do |variable_key, expected_value| # Set default variable value for this specific variable - if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key] + if assertion[:defaultVariableValues].is_a?(Hash) && assertion[:defaultVariableValues].key?(variable_key) override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key] end @@ -483,7 +570,7 @@ def run_test_feature_child(assertion, feature_key, instance, level) expected_variables = assertion[:expectedVariables] expected_variables.each do |variable_key, expected_value| # Set default variable value for this specific variable - if assertion[:defaultVariableValues] && assertion[:defaultVariableValues][variable_key] + if assertion[:defaultVariableValues].is_a?(Hash) && assertion[:defaultVariableValues].key?(variable_key) override_options[:default_variable_value] = assertion[:defaultVariableValues][variable_key] end @@ -522,6 +609,55 @@ def run_test_feature_child(assertion, feature_key, instance, level) end end + # Test expectedEvaluations + if assertion[:expectedEvaluations] + expected_evaluations = assertion[:expectedEvaluations] + + if expected_evaluations[:flag] + evaluation = evaluate_from_instance(instance, :flag, feature_key, context, override_options) + expected_evaluations[:flag].each do |key, expected_value| + actual_value = get_evaluation_value(evaluation, key) + if !compare_values(actual_value, expected_value) + has_error = true + errors += " ✘ expectedEvaluations.flag.#{key}: expected #{expected_value} but received #{actual_value}\n" + end + end + end + + if expected_evaluations[:variation] + evaluation = evaluate_from_instance(instance, :variation, feature_key, context, override_options) + expected_evaluations[:variation].each do |key, expected_value| + actual_value = get_evaluation_value(evaluation, key) + if !compare_values(actual_value, expected_value) + has_error = true + errors += " ✘ expectedEvaluations.variation.#{key}: expected #{expected_value} but received #{actual_value}\n" + end + end + end + + if expected_evaluations[:variables] + expected_evaluations[:variables].each do |variable_key, expected_eval| + if expected_eval.is_a?(Hash) + evaluation = evaluate_from_instance( + instance, + :variable, + feature_key, + context, + override_options, + variable_key + ) + expected_eval.each do |key, expected_value| + actual_value = get_evaluation_value(evaluation, key) + if !compare_values(actual_value, expected_value) + has_error = true + errors += " ✘ expectedEvaluations.variables.#{variable_key}.#{key}: expected #{expected_value} but received #{actual_value}\n" + end + end + end + end + end + end + duration = Time.now - start_time { @@ -614,13 +750,47 @@ def parse_sticky(sticky_data) def create_override_options(assertion) options = {} - if assertion[:defaultVariationValue] + if assertion.key?(:defaultVariationValue) options[:default_variation_value] = assertion[:defaultVariationValue] end options end + def evaluate_from_instance(instance, type, feature_key, context, override_options, variable_key = nil) + method_name = :"evaluate_#{type}" + + if instance.respond_to?(method_name) + if variable_key.nil? + return instance.send(method_name, feature_key, context, override_options) + end + + return instance.send(method_name, feature_key, variable_key, context, override_options) + end + + if instance.respond_to?(:parent) && instance.respond_to?(:sticky) + parent = instance.parent + combined_context = if instance.respond_to?(:context) + { **(instance.context || {}), **context } + else + context + end + + combined_options = { + sticky: instance.sticky, + **override_options + } + + if variable_key.nil? + return parent.send(method_name, feature_key, combined_context, combined_options) + end + + return parent.send(method_name, feature_key, variable_key, combined_context, combined_options) + end + + {} + end + def get_evaluation_value(evaluation, key) case key when :type @@ -659,6 +829,8 @@ def get_evaluation_value(evaluation, key) evaluation[:variable_value] when :variableSchema evaluation[:variable_schema] + when :variableOverrideIndex + evaluation[:variable_override_index] else nil end diff --git a/lib/featurevisor/evaluate.rb b/lib/featurevisor/evaluate.rb index 97f461e..725243f 100644 --- a/lib/featurevisor/evaluate.rb +++ b/lib/featurevisor/evaluate.rb @@ -17,7 +17,8 @@ module EvaluationReason VARIABLE_NOT_FOUND = "variable_not_found" # variable's schema is not defined in the feature VARIABLE_DEFAULT = "variable_default" # default variable value used VARIABLE_DISABLED = "variable_disabled" # feature is disabled, and variable's disabledValue is used - VARIABLE_OVERRIDE = "variable_override" # variable overridden from inside a variation + VARIABLE_OVERRIDE_VARIATION = "variable_override_variation" # variable overridden from inside a variation + VARIABLE_OVERRIDE_RULE = "variable_override_rule" # variable overridden from inside a rule # Common NO_MATCH = "no_match" # no rules matched @@ -55,14 +56,14 @@ def self.evaluate_with_hooks(options) evaluation = evaluate(result_options) # Default: variation - if options[:default_variation_value] && + if options.key?(:default_variation_value) && evaluation[:type] == "variation" && evaluation[:variation_value].nil? evaluation[:variation_value] = options[:default_variation_value] end # Default: variable - if options[:default_variable_value] && + if options.key?(:default_variable_value) && evaluation[:type] == "variable" && evaluation[:variable_value].nil? evaluation[:variable_value] = options[:default_variable_value] @@ -132,10 +133,10 @@ def self.evaluate(options) if type == "variable" if feature && variable_key && feature[:variablesSchema] && - (feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym]) - variable_schema = feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym] + has_key?(feature[:variablesSchema], variable_key) + variable_schema = fetch_with_symbol_key(feature[:variablesSchema], variable_key) - if variable_schema[:disabledValue] + if variable_schema.key?(:disabledValue) # disabledValue: evaluation = { type: type, @@ -179,8 +180,8 @@ def self.evaluate(options) end # Sticky - if sticky && (sticky[feature_key] || sticky[feature_key.to_sym]) - sticky_feature = sticky[feature_key] || sticky[feature_key.to_sym] + if sticky && has_key?(sticky, feature_key) + sticky_feature = fetch_with_symbol_key(sticky, feature_key) # flag if type == "flag" && sticky_feature.key?(:enabled) @@ -201,7 +202,7 @@ def self.evaluate(options) if type == "variation" variation_value = sticky_feature[:variation] - if variation_value + unless variation_value.nil? evaluation = { type: type, feature_key: feature_key, @@ -219,8 +220,8 @@ def self.evaluate(options) if type == "variable" && variable_key variables = sticky_feature[:variables] - if variables && (variables[variable_key] || variables[variable_key.to_sym]) - variable_value = variables[variable_key] || variables[variable_key.to_sym] + if variables && has_key?(variables, variable_key) + variable_value = fetch_with_symbol_key(variables, variable_key) evaluation = { type: type, feature_key: feature_key, @@ -261,8 +262,8 @@ def self.evaluate(options) variable_schema = nil if variable_key - if feature[:variablesSchema] && (feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym]) - variable_schema = feature[:variablesSchema][variable_key] || feature[:variablesSchema][variable_key.to_sym] + if feature[:variablesSchema] && has_key?(feature[:variablesSchema], variable_key) + variable_schema = fetch_with_symbol_key(feature[:variablesSchema], variable_key) end # variable schema not found @@ -343,8 +344,8 @@ def self.evaluate(options) end # variable - if variable_key && force[:variables] && (force[:variables][variable_key] || force[:variables][variable_key.to_sym]) - variable_value = force[:variables][variable_key] || force[:variables][variable_key.to_sym] + if variable_key && force[:variables] && has_key?(force[:variables], variable_key) + variable_value = fetch_with_symbol_key(force[:variables], variable_key) evaluation = { type: type, feature_key: feature_key, @@ -385,8 +386,8 @@ def self.evaluate(options) required_variation_value = nil - if required_variation_evaluation[:variation_value] - required_variation_value = required_variation_evaluation[:variation_value] + if has_key?(required_variation_evaluation, :variation_value) + required_variation_value = fetch_with_symbol_key(required_variation_evaluation, :variation_value) elsif required_variation_evaluation[:variation] required_variation_value = required_variation_evaluation[:variation][:value] end @@ -601,10 +602,50 @@ def self.evaluate(options) # variable if type == "variable" && variable_key # override from rule + if matched_traffic && + matched_traffic[:variableOverrides] && + has_key?(matched_traffic[:variableOverrides], variable_key) + overrides = fetch_with_symbol_key(matched_traffic[:variableOverrides], variable_key) + + override_index = overrides.find_index do |o| + if o[:conditions] + conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions] + datafile_reader.all_conditions_are_matched(conditions, context) + elsif o[:segments] + segments = datafile_reader.parse_segments_if_stringified(o[:segments]) + datafile_reader.all_segments_are_matched(segments, context) + else + false + end + end + + unless override_index.nil? + override = overrides[override_index] + + evaluation = { + type: type, + feature_key: feature_key, + reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE, + bucket_key: bucket_key, + bucket_value: bucket_value, + rule_key: matched_traffic[:key], + traffic: matched_traffic, + variable_key: variable_key, + variable_schema: variable_schema, + variable_value: override[:value], + variable_override_index: override_index + } + + logger.debug("variable override from rule", evaluation) + + return evaluation + end + end + if matched_traffic && matched_traffic[:variables] && - (matched_traffic[:variables][variable_key] || matched_traffic[:variables][variable_key.to_sym]) - variable_value = matched_traffic[:variables][variable_key] || matched_traffic[:variables][variable_key.to_sym] + has_key?(matched_traffic[:variables], variable_key) + variable_value = fetch_with_symbol_key(matched_traffic[:variables], variable_key) evaluation = { type: type, feature_key: feature_key, @@ -637,81 +678,38 @@ def self.evaluate(options) if variation_value && feature[:variations].is_a?(Array) variation = feature[:variations].find { |v| v[:value] == variation_value } - if variation && variation[:variableOverrides] && (variation[:variableOverrides][variable_key] || variation[:variableOverrides][variable_key.to_sym]) - overrides = variation[:variableOverrides][variable_key] || variation[:variableOverrides][variable_key.to_sym] + if variation && variation[:variableOverrides] && has_key?(variation[:variableOverrides], variable_key) + overrides = fetch_with_symbol_key(variation[:variableOverrides], variable_key) - logger.debug("checking variableOverrides", { - feature_key: feature_key, - variable_key: variable_key, - overrides: overrides, - context: context - }) - - override = overrides.find do |o| - logger.debug("evaluating override", { - feature_key: feature_key, - variable_key: variable_key, - override: o, - context: context - }) - - result = if o[:conditions] - matched = datafile_reader.all_conditions_are_matched( - o[:conditions].is_a?(String) && o[:conditions] != "*" ? - JSON.parse(o[:conditions]) : o[:conditions], - context - ) - logger.debug("conditions match result", { - feature_key: feature_key, - variable_key: variable_key, - conditions: o[:conditions], - matched: matched - }) - matched + override_index = overrides.find_index do |o| + if o[:conditions] + conditions = o[:conditions].is_a?(String) && o[:conditions] != "*" ? JSON.parse(o[:conditions]) : o[:conditions] + datafile_reader.all_conditions_are_matched(conditions, context) elsif o[:segments] segments = datafile_reader.parse_segments_if_stringified(o[:segments]) - matched = datafile_reader.all_segments_are_matched(segments, context) - logger.debug("segments match result", { - feature_key: feature_key, - variable_key: variable_key, - segments: o[:segments], - parsed_segments: segments, - matched: matched - }) - matched + datafile_reader.all_segments_are_matched(segments, context) else - logger.debug("override has no conditions or segments", { - feature_key: feature_key, - variable_key: variable_key, - override: o - }) false end - - logger.debug("override evaluation result", { - feature_key: feature_key, - variable_key: variable_key, - result: result - }) - - result end - if override + unless override_index.nil? + override = overrides[override_index] evaluation = { type: type, feature_key: feature_key, - reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE, + reason: Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_VARIATION, bucket_key: bucket_key, bucket_value: bucket_value, rule_key: matched_traffic&.[](:key), traffic: matched_traffic, variable_key: variable_key, variable_schema: variable_schema, - variable_value: override[:value] + variable_value: override[:value], + variable_override_index: override_index } - logger.debug("variable override", evaluation) + logger.debug("variable override from variation", evaluation) return evaluation end @@ -719,8 +717,8 @@ def self.evaluate(options) if variation && variation[:variables] && - (variation[:variables][variable_key] || variation[:variables][variable_key.to_sym]) - variable_value = variation[:variables][variable_key] || variation[:variables][variable_key.to_sym] + has_key?(variation[:variables], variable_key) + variable_value = fetch_with_symbol_key(variation[:variables], variable_key) evaluation = { type: type, feature_key: feature_key, @@ -814,5 +812,20 @@ def self.evaluate(options) evaluation end end + + def self.fetch_with_symbol_key(obj, key) + return obj[key] if obj.is_a?(Hash) && obj.key?(key) + + symbol_key = key.to_sym + return obj[symbol_key] if obj.is_a?(Hash) && obj.key?(symbol_key) + + nil + end + + def self.has_key?(obj, key) + return false unless obj.is_a?(Hash) + + obj.key?(key) || obj.key?(key.to_sym) + end end end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 2322fd4..643de6e 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -40,10 +40,18 @@ RSpec.describe FeaturevisorCLI::Parser do describe ".parse" do it "parses basic options" do - options = FeaturevisorCLI::Parser.parse(["test", "--verbose", "--n=5000"]) + options = FeaturevisorCLI::Parser.parse(["test", "--verbose", "--n=5000", "--with-scopes", "--with-tags"]) expect(options.command).to eq("test") expect(options.verbose).to be true expect(options.n).to eq(5000) + expect(options.with_scopes).to be true + expect(options.with_tags).to be true + end + + it "parses camelCase aliases for scope/tag flags" do + options = FeaturevisorCLI::Parser.parse(["test", "--withScopes", "--withTags"]) + expect(options.with_scopes).to be true + expect(options.with_tags).to be true end it "sets default values" do diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index cc22591..80eac3f 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -305,12 +305,51 @@ expect(Featurevisor::Conditions.condition_is_matched(condition, context, get_regex)).to be true end + it "should handle semverNotEquals" do + condition = { attribute: "version", operator: "semverNotEquals", value: "1.0.0" } + + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "2.0.0" }, get_regex) + ).to be true + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "1.0.0" }, get_regex) + ).to be false + end + + it "should handle semverGreaterThanOrEquals" do + condition = { attribute: "version", operator: "semverGreaterThanOrEquals", value: "1.0.0" } + + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "2.0.0" }, get_regex) + ).to be true + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "1.0.0" }, get_regex) + ).to be true + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "0.9.0" }, get_regex) + ).to be false + end + it "should handle semverLessThan" do condition = { attribute: "version", operator: "semverLessThan", value: "1.0.0" } context = { version: "0.9.0" } expect(Featurevisor::Conditions.condition_is_matched(condition, context, get_regex)).to be true end + + it "should handle semverLessThanOrEquals" do + condition = { attribute: "version", operator: "semverLessThanOrEquals", value: "1.0.0" } + + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "1.0.0" }, get_regex) + ).to be true + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "0.9.0" }, get_regex) + ).to be true + expect( + Featurevisor::Conditions.condition_is_matched(condition, { version: "1.1.0" }, get_regex) + ).to be false + end end describe "regex operators" do @@ -353,6 +392,215 @@ end end + describe "logical composition via datafile reader" do + it "should match with multiple conditions inside NOT" do + conditions = [ + { + not: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { attribute: "browser_version", operator: "equals", value: "1.0" } + ] + } + ] + + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "firefox", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "1.0" + }) + ).to be false + end + + it "should match with OR inside AND" do + conditions = [ + { + and: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { + or: [ + { attribute: "browser_version", operator: "equals", value: "1.0" }, + { attribute: "browser_version", operator: "equals", value: "2.0" } + ] + } + ] + } + ] + + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "1.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "3.0" + }) + ).to be false + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_version: "2.0" + }) + ).to be false + end + + it "should match with plain conditions, followed by OR inside AND" do + conditions = [ + { attribute: "country", operator: "equals", value: "nl" }, + { + and: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { + or: [ + { attribute: "browser_version", operator: "equals", value: "1.0" }, + { attribute: "browser_version", operator: "equals", value: "2.0" } + ] + } + ] + } + ] + + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "nl", + browser_type: "chrome", + browser_version: "1.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "nl", + browser_type: "chrome", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "3.0" + }) + ).to be false + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "us", + browser_version: "2.0" + }) + ).to be false + end + + it "should match with AND inside OR" do + conditions = [ + { + or: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { + and: [ + { attribute: "device_type", operator: "equals", value: "mobile" }, + { attribute: "orientation", operator: "equals", value: "portrait" } + ] + } + ] + } + ] + + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "chrome", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "firefox", + device_type: "mobile", + orientation: "portrait" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "firefox", + browser_version: "2.0" + }) + ).to be false + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "firefox", + device_type: "desktop" + }) + ).to be false + end + + it "should match with plain conditions, followed by AND inside OR" do + conditions = [ + { attribute: "country", operator: "equals", value: "nl" }, + { + or: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { + and: [ + { attribute: "device_type", operator: "equals", value: "mobile" }, + { attribute: "orientation", operator: "equals", value: "portrait" } + ] + } + ] + } + ] + + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "nl", + browser_type: "chrome", + browser_version: "2.0" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "nl", + browser_type: "firefox", + device_type: "mobile", + orientation: "portrait" + }) + ).to be true + expect( + datafile_reader.all_conditions_are_matched(conditions, { + browser_type: "firefox", + browser_version: "2.0" + }) + ).to be false + expect( + datafile_reader.all_conditions_are_matched(conditions, { + country: "de", + browser_type: "firefox", + device_type: "desktop" + }) + ).to be false + end + end + describe "error handling" do it "should handle errors gracefully and return false" do condition = { attribute: "name", operator: "invalid_operator", value: "test" } diff --git a/spec/datafile_reader_spec.rb b/spec/datafile_reader_spec.rb index f74963e..2fdbf0e 100644 --- a/spec/datafile_reader_spec.rb +++ b/spec/datafile_reader_spec.rb @@ -335,6 +335,33 @@ ).to be false end + it "should match dutchMobileOrDesktopUsers2" do + group = groups.find { |g| g[:key] == "dutchMobileOrDesktopUsers2" } + + # match + expect( + datafile_reader.all_segments_are_matched(group[:segments], { + country: "nl", + deviceType: "mobile" + }) + ).to be true + expect( + datafile_reader.all_segments_are_matched(group[:segments], { + country: "nl", + deviceType: "desktop" + }) + ).to be true + + # not match + expect(datafile_reader.all_segments_are_matched(group[:segments], {})).to be false + expect( + datafile_reader.all_segments_are_matched(group[:segments], { + country: "de", + deviceType: "mobile" + }) + ).to be false + end + it "should match germanMobileUsers" do group = groups.find { |g| g[:key] == "germanMobileUsers" } diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index c116c6f..53e373e 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -18,7 +18,8 @@ expect(Featurevisor::EvaluationReason::VARIABLE_NOT_FOUND).to eq("variable_not_found") expect(Featurevisor::EvaluationReason::VARIABLE_DEFAULT).to eq("variable_default") expect(Featurevisor::EvaluationReason::VARIABLE_DISABLED).to eq("variable_disabled") - expect(Featurevisor::EvaluationReason::VARIABLE_OVERRIDE).to eq("variable_override") + expect(Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE).to eq("variable_override_rule") + expect(Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_VARIATION).to eq("variable_override_variation") end it "should have common reasons" do @@ -318,6 +319,92 @@ expect(result[:required]).to eq(["required-feature"]) end + it "should treat required variation value false as a valid match" do + feature = { + key: "test-feature", + required: [{ key: "required-feature", variation: false }] + } + + allow(datafile_reader).to receive(:get_feature).and_return(feature) + allow(datafile_reader).to receive(:get_matched_force).and_return({ + force: nil, + forceIndex: nil + }) + allow(datafile_reader).to receive(:get_matched_traffic).and_return(nil) + allow(Featurevisor::Bucketer).to receive(:get_bucket_key).and_return("test.123") + allow(Featurevisor::Bucketer).to receive(:get_bucketed_number).and_return(50) + + allow(Featurevisor::Evaluate).to receive(:evaluate).and_call_original + allow(Featurevisor::Evaluate).to receive(:evaluate).with( + hash_including(type: "flag", feature_key: "required-feature") + ).and_return({ + type: "flag", + feature_key: "required-feature", + enabled: true + }) + allow(Featurevisor::Evaluate).to receive(:evaluate).with( + hash_including(type: "variation", feature_key: "required-feature") + ).and_return({ + type: "variation", + feature_key: "required-feature", + variation_value: false + }) + + result = Featurevisor::Evaluate.evaluate( + type: "flag", + feature_key: feature, + context: {}, + logger: logger, + hooks_manager: hooks_manager, + datafile_reader: datafile_reader + ) + + expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::REQUIRED) + end + + it "should treat required variation value 0 as a valid match" do + feature = { + key: "test-feature", + required: [{ key: "required-feature", variation: 0 }] + } + + allow(datafile_reader).to receive(:get_feature).and_return(feature) + allow(datafile_reader).to receive(:get_matched_force).and_return({ + force: nil, + forceIndex: nil + }) + allow(datafile_reader).to receive(:get_matched_traffic).and_return(nil) + allow(Featurevisor::Bucketer).to receive(:get_bucket_key).and_return("test.123") + allow(Featurevisor::Bucketer).to receive(:get_bucketed_number).and_return(50) + + allow(Featurevisor::Evaluate).to receive(:evaluate).and_call_original + allow(Featurevisor::Evaluate).to receive(:evaluate).with( + hash_including(type: "flag", feature_key: "required-feature") + ).and_return({ + type: "flag", + feature_key: "required-feature", + enabled: true + }) + allow(Featurevisor::Evaluate).to receive(:evaluate).with( + hash_including(type: "variation", feature_key: "required-feature") + ).and_return({ + type: "variation", + feature_key: "required-feature", + variation_value: 0 + }) + + result = Featurevisor::Evaluate.evaluate( + type: "flag", + feature_key: feature, + context: {}, + logger: logger, + hooks_manager: hooks_manager, + datafile_reader: datafile_reader + ) + + expect(result[:reason]).not_to eq(Featurevisor::EvaluationReason::REQUIRED) + end + it "should handle errors gracefully" do options = { type: "flag", @@ -337,5 +424,122 @@ expect(result[:error]).to be_a(StandardError) expect(result[:error].message).to eq("Test error") end + + it "should return variable override from rule with index" do + feature = { + key: "test-feature", + bucketBy: "userId", + variablesSchema: { + test_var: { + type: "boolean", + defaultValue: true + } + }, + traffic: [ + { + key: "rule-1", + segments: "*", + percentage: 100_000, + allocation: [], + variableOverrides: { + test_var: [ + { conditions: [{ attribute: "country", operator: "equals", value: "de" }], value: false }, + { conditions: [{ attribute: "country", operator: "equals", value: "nl" }], value: true } + ] + }, + variables: { + test_var: true + } + } + ], + variations: [] + } + + allow(datafile_reader).to receive(:get_matched_force).and_return({ + force: nil, + forceIndex: nil + }) + allow(datafile_reader).to receive(:get_matched_traffic).and_return(feature[:traffic][0]) + allow(datafile_reader).to receive(:get_matched_allocation).and_return(nil) + allow(datafile_reader).to receive(:all_conditions_are_matched).and_return(false, true) + allow(Featurevisor::Bucketer).to receive(:get_bucket_key).and_return("user.test-feature") + allow(Featurevisor::Bucketer).to receive(:get_bucketed_number).and_return(1) + + result = Featurevisor::Evaluate.evaluate( + type: "variable", + feature_key: feature, + variable_key: :test_var, + context: { country: "nl" }, + logger: logger, + hooks_manager: hooks_manager, + datafile_reader: datafile_reader + ) + + expect(result[:reason]).to eq(Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_RULE) + expect(result[:variable_value]).to be true + expect(result[:variable_override_index]).to eq(1) + end + + it "should return variable override from variation with index" do + feature = { + key: "test-feature", + bucketBy: "userId", + variablesSchema: { + test_var: { + type: "boolean", + defaultValue: true + } + }, + traffic: [ + { + key: "rule-1", + segments: "*", + percentage: 100_000, + variation: "treatment", + allocation: [ + { variation: "treatment", range: [0, 100_000] } + ] + } + ], + variations: [ + { + value: "treatment", + variableOverrides: { + test_var: [ + { conditions: [{ attribute: "country", operator: "equals", value: "de" }], value: true }, + { conditions: [{ attribute: "country", operator: "equals", value: "nl" }], value: false } + ] + }, + variables: { + test_var: true + } + } + ] + } + + allow(datafile_reader).to receive(:get_matched_force).and_return({ + force: nil, + forceIndex: nil + }) + allow(datafile_reader).to receive(:get_matched_traffic).and_return(feature[:traffic][0]) + allow(datafile_reader).to receive(:get_matched_allocation).and_return(feature[:traffic][0][:allocation][0]) + allow(datafile_reader).to receive(:all_conditions_are_matched).and_return(false, true) + allow(Featurevisor::Bucketer).to receive(:get_bucket_key).and_return("user.test-feature") + allow(Featurevisor::Bucketer).to receive(:get_bucketed_number).and_return(1) + + result = Featurevisor::Evaluate.evaluate( + type: "variable", + feature_key: feature, + variable_key: :test_var, + context: { country: "nl" }, + logger: logger, + hooks_manager: hooks_manager, + datafile_reader: datafile_reader + ) + + expect(result[:reason]).to eq(Featurevisor::EvaluationReason::VARIABLE_OVERRIDE_VARIATION) + expect(result[:variable_value]).to be false + expect(result[:variable_override_index]).to eq(1) + end end end diff --git a/spec/events_spec.rb b/spec/events_spec.rb new file mode 100644 index 0000000..06987b3 --- /dev/null +++ b/spec/events_spec.rb @@ -0,0 +1,136 @@ +require "featurevisor" + +RSpec.describe Featurevisor::Events do + let(:logger) { Featurevisor.create_logger(level: "error") } + + describe ".get_params_for_sticky_set_event" do + it "should get params for sticky set event: empty to new" do + previous_sticky_features = {} + new_sticky_features = { + feature2: { enabled: true }, + feature3: { enabled: true } + } + replace = true + + result = described_class.get_params_for_sticky_set_event( + previous_sticky_features, + new_sticky_features, + replace + ) + + expect(result).to eq({ + features: %i[feature2 feature3], + replaced: replace + }) + end + + it "should get params for sticky set event: add, change, remove" do + previous_sticky_features = { + feature1: { enabled: true }, + feature2: { enabled: true } + } + new_sticky_features = { + feature2: { enabled: true }, + feature3: { enabled: true } + } + replace = true + + result = described_class.get_params_for_sticky_set_event( + previous_sticky_features, + new_sticky_features, + replace + ) + + expect(result).to eq({ + features: %i[feature1 feature2 feature3], + replaced: replace + }) + end + end + + describe ".get_params_for_datafile_set_event" do + def build_reader(revision:, features:) + Featurevisor::DatafileReader.new( + datafile: { + schemaVersion: "1.0.0", + revision: revision, + features: features, + segments: {} + }, + logger: logger + ) + end + + it "should get params for datafile set event: empty to new" do + previous_reader = build_reader(revision: "1", features: {}) + new_reader = build_reader( + revision: "2", + features: { + feature1: { bucketBy: "userId", hash: "hash1", traffic: [] }, + feature2: { bucketBy: "userId", hash: "hash2", traffic: [] } + } + ) + + result = described_class.get_params_for_datafile_set_event(previous_reader, new_reader) + + expect(result).to eq({ + revision: "2", + previous_revision: "1", + revision_changed: true, + features: %i[feature1 feature2] + }) + end + + it "should get params for datafile set event: change hash, addition" do + previous_reader = build_reader( + revision: "1", + features: { + feature1: { bucketBy: "userId", hash: "hash-same", traffic: [] }, + feature2: { bucketBy: "userId", hash: "hash1-2", traffic: [] } + } + ) + new_reader = build_reader( + revision: "2", + features: { + feature1: { bucketBy: "userId", hash: "hash-same", traffic: [] }, + feature2: { bucketBy: "userId", hash: "hash2-2", traffic: [] }, + feature3: { bucketBy: "userId", hash: "hash2-3", traffic: [] } + } + ) + + result = described_class.get_params_for_datafile_set_event(previous_reader, new_reader) + + expect(result).to eq({ + revision: "2", + previous_revision: "1", + revision_changed: true, + features: %i[feature2 feature3] + }) + end + + it "should get params for datafile set event: change hash, removal" do + previous_reader = build_reader( + revision: "1", + features: { + feature1: { bucketBy: "userId", hash: "hash-same", traffic: [] }, + feature2: { bucketBy: "userId", hash: "hash1-2", traffic: [] } + } + ) + new_reader = build_reader( + revision: "2", + features: { + feature2: { bucketBy: "userId", hash: "hash2-2", traffic: [] } + } + ) + + result = described_class.get_params_for_datafile_set_event(previous_reader, new_reader) + + expect(result).to eq({ + revision: "2", + previous_revision: "1", + revision_changed: true, + features: %i[feature1 feature2] + }) + end + end +end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 9b6b1ca..4cea4ca 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1298,4 +1298,60 @@ expect(sdk.get_feature("manualTest")[:key]).to eq("manualTest") expect(sdk.is_enabled("manualTest", { userId: "123" })).to be true end + + describe "get_value_by_type" do + let(:sdk) do + Featurevisor.create_instance( + datafile: { + schemaVersion: "2", + revision: "1.0", + features: {}, + segments: {} + } + ) + end + + it "returns nil for type mismatch" do + expect(sdk.send(:get_value_by_type, 1, "string")).to be_nil + end + + it "returns string as is for string type" do + expect(sdk.send(:get_value_by_type, "1", "string")).to eq("1") + end + + it "returns boolean coercion result for boolean type" do + expect(sdk.send(:get_value_by_type, true, "boolean")).to be true + expect(sdk.send(:get_value_by_type, false, "boolean")).to be false + expect(sdk.send(:get_value_by_type, "true", "boolean")).to be false + end + + it "returns object value for object type" do + expect(sdk.send(:get_value_by_type, { a: 1, b: 2 }, "object")).to eq({ a: 1, b: 2 }) + end + + it "returns value as is for json type" do + json = JSON.generate({ a: 1, b: 2 }) + expect(sdk.send(:get_value_by_type, json, "json")).to eq(json) + end + + it "returns array value for array type" do + expect(sdk.send(:get_value_by_type, %w[1 2 3], "array")).to eq(%w[1 2 3]) + end + + it "parses integer for integer type" do + expect(sdk.send(:get_value_by_type, "1", "integer")).to eq(1) + end + + it "parses double for double type" do + expect(sdk.send(:get_value_by_type, "1.1", "double")).to eq(1.1) + end + + it "returns nil when value is nil" do + expect(sdk.send(:get_value_by_type, nil, "string")).to be_nil + end + + it "returns nil for unsupported value with string type" do + expect(sdk.send(:get_value_by_type, -> {}, "string")).to be_nil + end + end end diff --git a/spec/test_command_spec.rb b/spec/test_command_spec.rb new file mode 100644 index 0000000..9fb374d --- /dev/null +++ b/spec/test_command_spec.rb @@ -0,0 +1,204 @@ +require "featurevisor" +require "stringio" + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "bin")) +require "cli" + +RSpec.describe FeaturevisorCLI::Commands::Test do + let(:options) do + opts = FeaturevisorCLI::Options.new + opts.project_directory_path = "/tmp/featurevisor-project" + opts + end + + describe "datafile routing helpers" do + it "prefers scoped datafile over tagged and base datafile" do + command = described_class.new(options) + + datafiles_by_key = { + "production" => { schemaVersion: "2" }, + "production-tag-web" => { schemaVersion: "2", tagged: true }, + "production-scope-browsers" => { schemaVersion: "2", scoped: true } + } + + assertion = { + environment: "production", + scope: "browsers", + tag: "web" + } + + datafile = command.send(:resolve_datafile_for_assertion, assertion, datafiles_by_key) + expect(datafile[:scoped]).to be true + end + + it "returns parsed scope context by name" do + command = described_class.new(options) + config = { + scopes: [ + { name: "browsers", context: { "platform" => "web" } } + ] + } + + scope_context = command.send(:get_scope_context, config, "browsers") + expect(scope_context).to eq({ platform: "web" }) + end + end + + describe "build command generation" do + it "includes scope and environment flags when building scoped datafiles" do + command = described_class.new(options) + + expect(command).to receive(:execute_command) + .with(include("featurevisor build", "--environment=production", "--scope=browsers", "--json")) + .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') + + datafile = command.send( + :build_single_datafile, + environment: "production", + schema_version: "2", + inflate: nil, + scope: "browsers" + ) + + expect(datafile[:schemaVersion]).to eq("2") + end + + it "does not include environment flag when environment is false" do + command = described_class.new(options) + + expect(command).to receive(:execute_command) + .with(satisfy { |cmd| cmd.include?("featurevisor build") && !cmd.include?("--environment=") }) + .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') + + datafile = command.send( + :build_single_datafile, + environment: false, + schema_version: nil, + inflate: nil + ) + + expect(datafile[:schemaVersion]).to eq("2") + end + end + + describe "test execution behavior" do + it "evaluates expectedEvaluations in child assertions" do + command = described_class.new(options) + instance = double("child-instance") + + allow(instance).to receive(:is_enabled).and_return(true) + allow(instance).to receive(:get_variation).and_return("control") + allow(instance).to receive(:get_variable).and_return("v") + allow(instance).to receive(:evaluate_flag).and_return({ type: "flag", enabled: false }) + allow(instance).to receive(:evaluate_variation).and_return({ type: "variation", variation_value: "control" }) + allow(instance).to receive(:evaluate_variable).and_return({ type: "variable", variable_key: "k", variable_value: "v" }) + + assertion = { + expectedEvaluations: { + flag: { + enabled: true + } + } + } + + result = command.send(:run_test_feature_child, assertion, "myFeature", instance, "warn") + expect(result[:has_error]).to be true + expect(result[:errors]).to include("expectedEvaluations.flag.enabled") + end + + it "counts missing-datafile assertion as failed" do + command = described_class.new(options) + + allow(command).to receive(:exit).and_raise(SystemExit.new(1)) + + tests = [ + { + key: "features/missing.spec", + feature: "foo", + assertions: [ + { + description: "missing datafile assertion", + environment: "production", + scope: "browsers" + } + ] + } + ] + + output = StringIO.new + original_stdout = $stdout + $stdout = output + begin + expect do + command.send(:run_tests, tests, {}, {}, "warn", { scopes: [] }) + end.to raise_error(SystemExit) + ensure + $stdout = original_stdout + end + + expect(output.string).to include("no datafile found for assertion scope/tag/environment combination") + expect(output.string).to include("Test specs: 0 passed, 1 failed") + expect(output.string).to include("Assertions: 0 passed, 1 failed") + end + end + + describe "comparison helpers" do + it "compares empty hashes and arrays as equal" do + command = described_class.new(options) + + expect(command.send(:compare_values, {}, {})).to be true + expect(command.send(:compare_values, [], [])).to be true + end + + it "treats hash key order as irrelevant" do + command = described_class.new(options) + + expect(command.send(:compare_values, { a: 1, b: 2 }, { b: 2, a: 1 })).to be true + end + + it "handles nested hash and array equality correctly" do + command = described_class.new(options) + + expect( + command.send( + :compare_values, + { a: { b: [1, { c: 2 }] }, d: 3 }, + { a: { b: [1, { c: 2 }] }, d: 3 } + ) + ).to be true + + expect( + command.send( + :compare_values, + { a: { b: [1, { c: 2 }] }, d: 3 }, + { a: { b: [1, { c: 4 }] }, d: 3 } + ) + ).to be false + end + + it "returns false for arrays with same elements in different order" do + command = described_class.new(options) + + expect(command.send(:compare_values, [1, 2, 3], [3, 2, 1])).to be false + end + + it "distinguishes nil hash values from missing keys" do + command = described_class.new(options) + + expect(command.send(:compare_values, { a: nil }, {})).to be false + end + + it "handles nil positions in arrays" do + command = described_class.new(options) + + expect(command.send(:compare_values, [nil, 2], [nil, 2])).to be true + expect(command.send(:compare_values, [2, nil], [nil, 2])).to be false + end + + it "returns false for different array lengths including extra nil at end" do + command = described_class.new(options) + + expect(command.send(:compare_values, [1, 2], [1, 2, nil])).to be false + end + end +end