diff --git a/Gemfile.lock b/Gemfile.lock index 75f80e6..ef4da6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,13 @@ PATH remote: . specs: - featurevisor (0.3.0) + featurevisor (1.0.0) + benchmark GEM remote: https://rubygems.org/ specs: + benchmark (0.4.0) diff-lcs (1.6.2) rake (13.3.0) rspec (3.13.1) diff --git a/Makefile b/Makefile index 135068a..3a11b6d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build test setup-monorepo update-monorepo +.PHONY: install build test test-example-1 setup-monorepo update-monorepo install: bundle install @@ -9,6 +9,10 @@ build: test: bundle exec rspec spec/ +test-example-1: + bundle exec rspec spec/ + bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures + setup-monorepo: mkdir -p monorepo if [ ! -d "monorepo/.git" ]; then \ diff --git a/README.md b/README.md index f3e5a21..0faed19 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Featurevisor Ruby SDK -This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications. +This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -23,20 +24,23 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Logging](#logging) +- [Evaluation details](#evaluation-details) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) -- [Evaluation details](#evaluation-details) -- [Hooks](#hooks) - - [Defining a hook](#defining-a-hook) - - [Registering hooks](#registering-hooks) + - [`error`](#error) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) - [CLI usage](#cli-usage) @@ -72,6 +76,18 @@ Or install it yourself as: $ gem install featurevisor ``` +## Public API + +The main runtime API is `Featurevisor.create_featurevisor`: + +```ruby +f = Featurevisor.create_featurevisor( + datafile: datafile_content +) +``` + +Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -89,7 +105,7 @@ response = Net::HTTP.get_response(URI(datafile_url)) datafile_content = JSON.parse(response.body, symbolize_names: true) # Create SDK instance -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( datafile: datafile_content ) ``` @@ -101,10 +117,10 @@ Alternatively, you can pass a JSON string directly and the SDK will parse it aut ```ruby # Option 1: Parse JSON yourself (recommended) datafile_content = JSON.parse(json_string, symbolize_names: true) -f = Featurevisor.create_instance(datafile: datafile_content) +f = Featurevisor.create_featurevisor(datafile: datafile_content) # Option 2: Pass JSON string directly (automatic parsing) -f = Featurevisor.create_instance(datafile: json_string) +f = Featurevisor.create_featurevisor(datafile: json_string) ``` ## Evaluation types @@ -142,7 +158,7 @@ You can set context at the time of initialization: ```ruby require 'featurevisor' -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( context: { deviceId: '123', country: 'nl' @@ -274,6 +290,8 @@ f.get_variable_object(feature_key, variable_key, context = {}) f.get_variable_json(feature_key, variable_key, context = {}) ``` +Type specific methods do not coerce values. `get_variable_integer` returns `nil` for the string `"1"`, and boolean getters return `nil` for non-boolean values. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: @@ -304,12 +322,14 @@ This is handy especially when you want to pass all evaluations from a backend ap For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, sticky: ...)` when a child needs its own sticky state. + ### Initialize with sticky ```ruby require 'featurevisor' -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( sticky: { myFeatureKey: { enabled: true, @@ -362,6 +382,49 @@ f.set_datafile(json_string) **Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself. +### Merging by default + +By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's current datafile: + +- top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile +- `segments` are merged, with incoming entries overriding existing ones +- `features` are merged, with incoming entries overriding existing ones + +This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. + +### Replacing + +To fully replace the stored datafile, pass `true` as the second argument: + +```ruby +f.set_datafile(datafile_content, true) +``` + +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: + +```ruby +require "open-uri" + +f = Featurevisor.create_featurevisor({}) + +def load_datafile(f, target) + url = "https://cdn.yoursite.com/production/featurevisor-#{target}.json" + datafile = JSON.parse(URI.open(url).read, symbolize_names: true) + + # merges into whatever was loaded before + f.set_datafile(datafile) +end + +load_datafile(f, "products") + +# later, when the user reaches checkout +load_datafile(f, "checkout") +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -400,65 +463,38 @@ end Thread.new { update_datafile(f, datafile_url) } ``` -## Logging +## Diagnostics -By default, Featurevisor SDKs will print out logs to the console for `info` level and above. +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -These are all the available log levels: - -- `error` -- `warn` -- `info` -- `debug` - -### Customizing levels - -If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization. - -Setting `debug` level will print out all logs, including `info`, `warn`, and `error` levels. +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -```ruby -require 'featurevisor' - -f = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: 'debug') -) -``` - -Alternatively, you can also set `log_level` directly: +Set the level during initialization or update it afterwards: ```ruby -f = Featurevisor.create_instance( - log_level: 'debug' -) -``` - -You can also set log level from SDK instance afterwards: - -```ruby -f.set_log_level('debug') +f = Featurevisor.create_featurevisor(log_level: "debug") +f.set_log_level("info") ``` ### Handler -You can also pass your own log handler, if you do not wish to print the logs to the console: +Use `on_diagnostic` to send structured diagnostics to your observability system: ```ruby -require 'featurevisor' - -f = Featurevisor.create_instance( - logger: Featurevisor.create_logger( - level: 'info', - handler: ->(level, message, details) { - # do something with the log - } - ) +f = Featurevisor.create_featurevisor( + log_level: "info", + on_diagnostic: ->(diagnostic) { + puts "#{diagnostic[:level]} #{diagnostic[:code]} #{diagnostic[:message]}" + } ) ``` -Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. +Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance. Evaluation metadata belongs in `:details`. + +Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events @@ -471,8 +507,8 @@ You can listen to these events that can occur at various stages in your applicat ```ruby unsubscribe = f.on('datafile_set') do |event| revision = event[:revision] # new revision - previous_revision = event[:previous_revision] - revision_changed = event[:revision_changed] # true if revision has changed + previous_revision = event[:previousRevision] + revision_changed = event[:revisionChanged] # true if revision has changed # list of feature keys that have new updates, # and you should re-evaluate them @@ -493,6 +529,8 @@ The `features` array will contain keys of features that have either been: compared to the previous datafile content that existed in the SDK instance. +The event also includes `replaced`, which is `true` when the datafile replaced the previous content instead of merging into it. + ### `context_set` ```ruby @@ -515,6 +553,20 @@ unsubscribe = f.on('sticky_set') do |event| end ``` +### `error` + +```ruby +unsubscribe = f.on('error') do |event| + diagnostic = event[:diagnostic] + code = diagnostic[:code] + message = diagnostic[:message] + + puts "Featurevisor error: #{code} #{message}" +end +``` + +The `error` event is emitted for diagnostics reported with `level: "error"`. + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: @@ -547,22 +599,33 @@ And optionally these properties depending on whether you are evaluating a featur - `variable_value`: the variable value - `variable_schema`: the variable schema -## Hooks +## Modules + +Modules allow you to intercept the evaluation process and customize SDK behavior. -Hooks allow you to intercept the evaluation process and customize it further as per your needs. +### Defining a module -### Defining a hook +A module is a simple hash with a unique recommended `name` and optional lifecycle functions: -A hook is a simple hash with a unique required `name` and optional functions: +If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. ```ruby require 'featurevisor' -my_custom_hook = { - # only required property - name: 'my-custom-hook', +my_custom_module = { + # recommended, and used for duplicate detection/removal + name: 'my-custom-module', + + # rest of the properties below are all optional per module + + # setup once, when the module is registered + setup: ->(api) { + revision = api[:get_revision].call - # rest of the properties below are all optional per hook + api[:on_diagnostic].call(->(diagnostic) { + puts diagnostic[:message] + }) + }, # before evaluation before: ->(options) { @@ -592,26 +655,43 @@ my_custom_hook = { bucket_value: ->(options) { # return custom bucket value options[:bucket_value] + }, + + # cleanup when module is removed or SDK is closed + close: -> { + # cleanup here } } ``` -### Registering hooks +The module API passed to `setup` exposes: + +- `get_revision` +- `on_diagnostic` +- `report_diagnostic` -You can register hooks at the time of SDK initialization: +### Registering modules + +You can register modules at the time of SDK initialization: ```ruby require 'featurevisor' -f = Featurevisor.create_instance( - hooks: [my_custom_hook] +f = Featurevisor.create_featurevisor( + modules: [my_custom_module] ) ``` Or after initialization: ```ruby -f.add_hook(my_custom_hook) +remove_module = f.add_module(my_custom_module) + +# remove later by calling the returned function +remove_module.call + +# or remove by name +f.remove_module('my-custom-module') ``` ## Child instance @@ -687,29 +767,24 @@ $ bundle exec featurevisor test \ --quiet|--verbose \ --onlyFailures \ --keyPattern="myFeatureKey" \ - --assertionPattern="#1" \ - --with-scopes \ - --with-tags + --assertionPattern="#1" ``` -`--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). +The Ruby test runner builds base datafiles and Target datafiles in memory via `npx featurevisor build --json`. When an assertion contains `target`, it is evaluated against the matching Target datafile. -For compatibility, camelCase aliases are also supported: `--withScopes` and `--withTags`. +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. ### 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 +$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures +$ make test-example-1 ``` ### Benchmark -Learn more about benchmarking [here](https://featurevisor.com/docs/cmd/#benchmarking). +Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). ```bash $ bundle exec featurevisor benchmark \ @@ -722,7 +797,7 @@ $ bundle exec featurevisor benchmark \ ### Assess distribution -Learn more about assessing distribution [here](https://featurevisor.com/docs/cmd/#assess-distribution). +Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution). ```bash $ bundle exec featurevisor assess-distribution \ diff --git a/bin/cli.rb b/bin/cli.rb index 1c21609..dfbf068 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -7,12 +7,13 @@ 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, :with_scopes, :with_tags + :populate_uuid, :with_scopes, :with_tags, :targets def initialize @n = 1000 @project_directory_path = Dir.pwd @populate_uuid = [] + @targets = [] end end @@ -82,15 +83,15 @@ def self.parse(args) options.show_datafile = true end - opts.on("--schemaVersion=VERSION", "Schema version") do |v| + opts.on("--schemaVersion=VERSION", "--schema-version=VERSION", "Legacy schema version option accepted and ignored") do |v| options.schema_version = v end - opts.on("--with-scopes", "--withScopes", "Test scoped assertions against scoped datafiles") do + opts.on("--with-scopes", "--withScopes", "Legacy scope option accepted and ignored") do options.with_scopes = true end - opts.on("--with-tags", "--withTags", "Test tagged assertions against tagged datafiles") do + opts.on("--with-tags", "--withTags", "Legacy tag option accepted and ignored") do options.with_tags = true end @@ -102,6 +103,10 @@ def self.parse(args) options.populate_uuid << v end + opts.on("--target=TARGET", "Target datafile; repeat for multiple targets") do |v| + options.targets << v unless options.targets.include?(v) + end + opts.on("-h", "--help", "Show this help message") do puts opts exit diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index cf72f00..60a4f66 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -29,13 +29,21 @@ def run exit 1 end - puts "" - puts "Assessing distribution for feature: \"#{@options.feature}\"..." - puts "" - - # Parse context if provided context = parse_context + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end + + private + + def run_for_target(context, target) + puts "\nAssess Featurevisor distribution" + puts " Feature: #{@options.feature}" + puts " Environment: #{@options.environment}" + puts " Target: #{target}" if target + puts " Iterations: #{@options.n}" + # Print context information if @options.context puts "Against context: #{@options.context}" @@ -47,10 +55,10 @@ def run puts "" # Build datafile - datafile = build_datafile(@options.environment) + datafile = build_datafile(@options.environment, target) # Create SDK instance - instance = create_instance(datafile) + instance = create_featurevisor(datafile) # Check if feature has variations feature = instance.get_feature(@options.feature) @@ -104,7 +112,21 @@ def run end end - private + def resolve_targets + return [] if @options.targets.empty? + stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) + unless status.success? + puts stderr + exit 1 + end + available = JSON.parse(stdout).map { |item| item.is_a?(Hash) ? (item["key"] || item["name"]) : item } + unknown = @options.targets.find { |target| !available.include?(target) } + if unknown + puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." + exit 1 + end + @options.targets + end def parse_context if @options.context @@ -121,15 +143,12 @@ def parse_context end end - def build_datafile(environment) + def build_datafile(environment, target = nil) puts "Building datafile for environment: #{environment}..." # Build the command similar to Go implementation command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - - if @options.schema_version - command_parts << "--schemaVersion=#{@options.schema_version}" - end + command_parts << "--target=#{target}" if target if @options.inflate command_parts << "--inflate=#{@options.inflate}" @@ -159,9 +178,9 @@ def execute_command(command) [stdout, stderr, exit_status.exitstatus] end - def create_instance(datafile) + def create_featurevisor(datafile) # Create SDK instance - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: datafile, log_level: get_logger_level ) diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 987790d..bb56a48 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -27,29 +27,32 @@ def run exit 1 end - puts "" - puts "Running benchmark for feature \"#{@options.feature}\"..." - puts "" - - # Parse context if provided context = parse_context + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end - puts "Building datafile containing all features for \"#{@options.environment}\"..." - datafile_build_start = Time.now + private - # Build datafile by executing the featurevisor build command - datafile = build_datafile(@options.environment) + def run_for_target(context, target) + datafile_build_start = Time.now + datafile = build_datafile(@options.environment, target) datafile_build_duration = Time.now - datafile_build_start datafile_build_duration_ms = (datafile_build_duration * 1000).round - puts "Datafile build duration: #{datafile_build_duration_ms}ms" + puts "\nBenchmark Featurevisor feature" + puts " Feature: #{@options.feature}" + puts " Environment: #{@options.environment}" + puts " Target: #{target}" if target + puts " Iterations: #{@options.n}" + puts " Build duration: #{datafile_build_duration_ms}ms" # Calculate datafile size datafile_size = datafile.to_json.bytesize puts "Datafile size: #{(datafile_size / 1024.0).round(2)} kB" # Create SDK instance with the datafile - instance = create_instance(datafile) + instance = create_featurevisor(datafile) puts "...SDK initialized" puts "" @@ -73,10 +76,26 @@ def run value_output = format_value(output[:value]) puts "Evaluated value : #{value_output}" puts "Total duration : #{pretty_duration(output[:duration])}" - puts "Average duration: #{pretty_duration(output[:duration] / @options.n)}" + puts "Minimum duration: #{format_duration_ms(output[:min_duration])}" + puts "Average duration: #{format_duration_ms(output[:average_duration])}" + puts "Maximum duration: #{format_duration_ms(output[:max_duration])}" end - private + def resolve_targets + return [] if @options.targets.empty? + stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) + unless status.success? + puts stderr + exit 1 + end + available = JSON.parse(stdout).map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target } + unknown = @options.targets.find { |target| !available.include?(target) } + if unknown + puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." + exit 1 + end + @options.targets + end def parse_context if @options.context @@ -93,24 +112,15 @@ def parse_context end end - def build_datafile(environment) + def build_datafile(environment, target = nil) puts "Building datafile for environment: #{environment}..." - # Build the command similar to Go implementation command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - - # Add schema version if specified - if @options.schema_version && !@options.schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--json"] - end + command_parts << "--target=#{target}" if target # Add inflate if specified if @options.inflate && @options.inflate > 0 - if @options.schema_version && !@options.schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--inflate=#{@options.inflate}", "--json"] - else - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{@options.inflate}", "--json"] - end + command_parts << "--inflate=#{@options.inflate}" end command = command_parts.join(" ") @@ -142,9 +152,9 @@ def execute_command(command) stdout end - def create_instance(datafile) + def create_featurevisor(datafile) # Create a real Featurevisor instance - instance = Featurevisor.create_instance( + instance = Featurevisor.create_featurevisor( log_level: get_logger_level ) @@ -164,61 +174,53 @@ def get_logger_level end end - def benchmark_feature_flag(instance, feature_key, context, n) - start_time = Time.now - - # Get the actual feature value from the SDK - value = instance.is_enabled(feature_key, context) + def benchmark_evaluation(n) + value = nil + total_duration_ns = 0 + min_duration_ns = nil + max_duration_ns = 0 - # Benchmark the evaluation n.times do - instance.is_enabled(feature_key, context) + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + value = yield + duration_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time + + total_duration_ns += duration_ns + min_duration_ns = duration_ns if min_duration_ns.nil? || duration_ns < min_duration_ns + max_duration_ns = duration_ns if duration_ns > max_duration_ns end - duration = Time.now - start_time + duration = total_duration_ns / 1_000_000_000.0 { value: value, - duration: duration + duration: duration, + min_duration: (min_duration_ns || 0) / 1_000_000_000.0, + average_duration: duration / n, + max_duration: max_duration_ns / 1_000_000_000.0 } end - def benchmark_feature_variation(instance, feature_key, context, n) - start_time = Time.now + def format_duration_ms(duration) + format("%.6fms", duration * 1000) + end - # Get the actual feature variation from the SDK - value = instance.get_variation(feature_key, context) + def benchmark_feature_flag(instance, feature_key, context, n) + benchmark_evaluation(n) do + instance.is_enabled(feature_key, context) + end + end - # Benchmark the evaluation - n.times do + def benchmark_feature_variation(instance, feature_key, context, n) + benchmark_evaluation(n) do instance.get_variation(feature_key, context) end - - duration = Time.now - start_time - - { - value: value, - duration: duration - } end def benchmark_feature_variable(instance, feature_key, variable_key, context, n) - start_time = Time.now - - # Get the actual variable value from the SDK - value = instance.get_variable(feature_key, variable_key, context) - - # Benchmark the evaluation - n.times do + benchmark_evaluation(n) do instance.get_variable(feature_key, variable_key, context) end - - duration = Time.now - start_time - - { - value: value, - duration: duration - } end def format_value(value) diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 6d96d79..e6e3856 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -21,16 +21,17 @@ def run # Get project configuration config = get_config environments = get_environments(config) - segments_by_key = get_segments - - # Use CLI schemaVersion option or fallback to config - schema_version = @options.schema_version - if schema_version.nil? || schema_version.empty? - schema_version = config[:schemaVersion] + available_targets = get_targets + unknown_target = @options.targets.find { |target| !available_targets.include?(target) } + if unknown_target + puts "Unknown target \"#{unknown_target}\". Available targets: #{available_targets.empty? ? "none" : available_targets.join(", ")}." + exit 1 end + targets = @options.targets.empty? ? available_targets : @options.targets + segments_by_key = get_segments - # Build datafiles for all environments (+ scoped/tagged variants) - datafiles_by_key = build_datafiles(config, environments, schema_version, @options.inflate) + # Build base and Target datafiles for all environments. + datafiles_by_key = build_datafiles(environments, targets, @options.inflate) puts "" @@ -84,6 +85,34 @@ def get_segments end end + def get_targets + puts "Getting targets..." + command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --targets --json)" + targets_output = execute_command(command) + + begin + targets = JSON.parse(targets_output, symbolize_names: true) + + if targets.is_a?(Array) + targets.filter_map do |target| + if target.is_a?(Hash) + target[:name] || target[:key] + else + target + end + end + elsif targets.is_a?(Hash) + targets.keys + else + [] + end + rescue JSON::ParserError => e + puts "Error: Failed to parse targets JSON: #{e.message}" + puts "Command output: #{targets_output}" + exit 1 + end + end + def get_environments(config) environments = config[:environments] @@ -97,23 +126,15 @@ 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) + def target_datafile_key(environment, target) if environment == false - "tag-#{tag}" + "false-target-#{target}" else - "#{environment}-tag-#{tag}" + "#{environment}-target-#{target}" end end - def build_datafiles(config, environments, schema_version, inflate) + def build_datafiles(environments, targets, inflate) datafiles_by_key = {} environments.each do |environment| @@ -122,61 +143,35 @@ def build_datafiles(config, environments, schema_version, inflate) datafiles_by_key[base_datafile_key(environment)] = build_single_datafile( environment: environment, - schema_version: schema_version, inflate: inflate ) - 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 @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 + targets.each do |target| + puts "Building datafile for target: #{target}..." + datafiles_by_key[target_datafile_key(environment, target)] = build_single_datafile( + environment: environment, + inflate: inflate, + target: target + ) end end datafiles_by_key end - def build_single_datafile(environment:, schema_version:, inflate:, scope: nil, tag: nil) + def build_single_datafile(environment:, inflate:, target: 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)}" + if target + command_parts << "--target=#{Shellwords.escape(target.to_s)}" end command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" @@ -224,29 +219,15 @@ def get_tests end end - def get_scope_context(config, scope_name) - return {} unless scope_name && config[:scopes].is_a?(Array) - - 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 + target_key = assertion[:target] ? target_datafile_key(environment, assertion[:target]) : nil base_key = base_datafile_key(environment) - if scoped_key && datafiles_by_key.key?(scoped_key) - return datafiles_by_key[scoped_key] - end - - if tagged_key && datafiles_by_key.key?(tagged_key) - return datafiles_by_key[tagged_key] + if target_key && datafiles_by_key.key?(target_key) + return datafiles_by_key[target_key] end datafiles_by_key[base_key] @@ -255,19 +236,19 @@ def resolve_datafile_for_assertion(assertion, datafiles_by_key) def create_tester_instance(datafile, level, assertion) sticky = parse_sticky(assertion[:sticky]) - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: datafile, sticky: sticky, log_level: level, - hooks: [ + modules: [ { - name: "tester-hook", + name: "test-module", bucket_value: ->(options) do at = assertion[:at] if at.is_a?(Numeric) (at * 1000).to_i else - options.bucket_value + options[:bucket_value] end end } @@ -284,6 +265,12 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) tests.each do |test| test_key = test[:key] assertions = test[:assertions] || [] + if test[:feature] && !@options.targets.empty? + assertions = assertions.select do |assertion| + assertion[:target].nil? || @options.targets.include?(assertion[:target]) + end + next if assertions.empty? + end results = "" test_has_error = false test_duration = 0.0 @@ -297,19 +284,13 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) if datafile.nil? test_result = { has_error: true, - errors: " ✘ no datafile found for assertion scope/tag/environment combination\n", + errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } 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 @@ -318,7 +299,7 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) puts "" end - test_result = run_test_feature(assertion, test[:feature], instance, level, scope_context) + test_result = run_test_feature(assertion, test[:feature], instance, level) end elsif test[:segment] segment_key = test[:segment] @@ -366,9 +347,8 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) end end - def run_test_feature(assertion, feature_key, instance, level, scope_context = {}) + def run_test_feature(assertion, feature_key, instance, level) 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 @@ -680,7 +660,7 @@ def run_test_segment(assertion, segment, level) } # Create SDK instance for segment testing - instance = Featurevisor.create_instance( + instance = Featurevisor.create_featurevisor( datafile: datafile, log_level: level ) @@ -768,16 +748,14 @@ def evaluate_from_instance(instance, type, feature_key, context, override_option 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 + if instance.is_a?(Featurevisor::ChildInstance) + parent = instance.instance_variable_get(:@parent) + child_context = instance.instance_variable_get(:@context) || {} + child_sticky = instance.instance_variable_get(:@sticky) + combined_context = { **child_context, **context } combined_options = { - sticky: instance.sticky, + __featurevisor_child_sticky: child_sticky, **override_options } diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} diff --git a/featurevisor.gemspec b/featurevisor.gemspec index 6e201ff..7a42ea8 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -15,6 +15,7 @@ Gem::Specification.new do |spec| spec.executables = ["featurevisor"] spec.require_paths = ["lib"] + spec.add_dependency "benchmark", ">= 0" spec.add_development_dependency "rspec", "~> 3.12" spec.add_development_dependency "rake", "~> 13.0" end diff --git a/lib/featurevisor.rb b/lib/featurevisor.rb index b8abcd7..c1a7725 100644 --- a/lib/featurevisor.rb +++ b/lib/featurevisor.rb @@ -6,7 +6,7 @@ require_relative "featurevisor/conditions" require_relative "featurevisor/datafile_reader" require_relative "featurevisor/bucketer" -require_relative "featurevisor/hooks" +require_relative "featurevisor/modules" require_relative "featurevisor/evaluate" require_relative "featurevisor/instance" require_relative "featurevisor/child_instance" @@ -14,4 +14,7 @@ module Featurevisor class Error < StandardError; end + + private_constant :DatafileReader + private_constant :Logger end diff --git a/lib/featurevisor/child_instance.rb b/lib/featurevisor/child_instance.rb index 9567853..87535ca 100644 --- a/lib/featurevisor/child_instance.rb +++ b/lib/featurevisor/child_instance.rb @@ -3,8 +3,6 @@ module Featurevisor # Child instance class for managing child contexts and sticky features class ChildInstance - attr_reader :parent, :context, :sticky, :emitter - # Initialize a new child instance # @param options [Hash] Child instance options # @option options [Instance] :parent Parent instance @@ -94,8 +92,8 @@ def is_enabled(feature_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -113,8 +111,8 @@ def get_variation(feature_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -134,8 +132,8 @@ def get_variable(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -155,8 +153,8 @@ def get_variable_boolean(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -176,8 +174,8 @@ def get_variable_string(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -197,8 +195,8 @@ def get_variable_integer(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -218,8 +216,8 @@ def get_variable_double(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -239,8 +237,8 @@ def get_variable_array(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -260,8 +258,8 @@ def get_variable_object(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -281,8 +279,8 @@ def get_variable_json(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -300,8 +298,8 @@ def get_all_evaluations(context = {}, feature_keys = [], options = {}) }, feature_keys, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end diff --git a/lib/featurevisor/datafile_reader.rb b/lib/featurevisor/datafile_reader.rb index 7a8f91f..1bede67 100644 --- a/lib/featurevisor/datafile_reader.rb +++ b/lib/featurevisor/datafile_reader.rb @@ -5,7 +5,7 @@ module Featurevisor # DatafileReader class for reading and processing Featurevisor datafiles class DatafileReader - attr_reader :schema_version, :revision, :segments, :features, :logger, :regex_cache + attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :logger, :regex_cache # Initialize a new DatafileReader # @param options [Hash] Options hash containing datafile and logger @@ -17,6 +17,7 @@ def initialize(options) @schema_version = datafile[:schemaVersion] @revision = datafile[:revision] + @featurevisor_version = datafile[:featurevisorVersion] @segments = (datafile[:segments] || {}).transform_keys(&:to_sym) @features = (datafile[:features] || {}).transform_keys(&:to_sym) @@ -65,6 +66,16 @@ def get_schema_version @schema_version end + def get_datafile + { + schemaVersion: @schema_version, + revision: @revision, + featurevisorVersion: @featurevisor_version, + segments: @segments, + features: @features + }.compact + end + # Get a segment by key # @param segment_key [String] Segment key to retrieve # @return [Hash, nil] Segment data or nil if not found @@ -172,15 +183,11 @@ def all_conditions_are_matched(conditions, context) end if conditions.is_a?(Hash) && conditions[:not] && conditions[:not].is_a?(Array) - return conditions[:not].all? do - all_conditions_are_matched({ and: conditions[:not] }, context) == false - end + return !all_conditions_are_matched({ and: conditions[:not] }, context) end if conditions.is_a?(Hash) && conditions["not"] && conditions["not"].is_a?(Array) - return conditions["not"].all? do - all_conditions_are_matched({ "and" => conditions["not"] }, context) == false - end + return !all_conditions_are_matched({ "and" => conditions["not"] }, context) end @@ -237,15 +244,11 @@ def all_segments_are_matched(group_segments, context) end if group_segments[:not] && group_segments[:not].is_a?(Array) - return group_segments[:not].all? do - all_segments_are_matched({ and: group_segments[:not] }, context) == false - end + return !all_segments_are_matched({ and: group_segments[:not] }, context) end if group_segments["not"] && group_segments["not"].is_a?(Array) - return group_segments["not"].all? do - all_segments_are_matched({ "and" => group_segments["not"] }, context) == false - end + return !all_segments_are_matched({ "and" => group_segments["not"] }, context) end @@ -351,4 +354,6 @@ def parse_segments_if_stringified(segments) segments end end + + private_constant :DatafileReader end diff --git a/lib/featurevisor/emitter.rb b/lib/featurevisor/emitter.rb index 8e1bb7c..f55a835 100644 --- a/lib/featurevisor/emitter.rb +++ b/lib/featurevisor/emitter.rb @@ -2,7 +2,7 @@ module Featurevisor # Event names for the emitter - EVENT_NAMES = %w[datafile_set context_set sticky_set].freeze + EVENT_NAMES = %w[datafile_set context_set sticky_set error].freeze # Event emitter class for handling event subscriptions and triggers class Emitter @@ -42,7 +42,7 @@ def trigger(event_name, details = {}) return unless listeners - listeners.each do |listener| + listeners.dup.each do |listener| begin listener.call(details) rescue => err diff --git a/lib/featurevisor/evaluate.rb b/lib/featurevisor/evaluate.rb index 725243f..9d8c03e 100644 --- a/lib/featurevisor/evaluate.rb +++ b/lib/featurevisor/evaluate.rb @@ -36,20 +36,17 @@ module EvaluationReason # Evaluation module for feature flag evaluation module Evaluate - # Evaluate with hooks + # Evaluate with modules # @param options [Hash] Evaluation options # @return [Hash] Evaluation result - def self.evaluate_with_hooks(options) + def self.evaluate_with_modules(options) begin - hooks_manager = options[:hooks_manager] - hooks = hooks_manager.get_all + modules_manager = options[:modules_manager] - # Run before hooks + # Run before modules result_options = options - hooks.each do |hook| - if hook.respond_to?(:call_before) - result_options = hook.call_before(result_options) - end + if modules_manager + result_options = modules_manager.run_before_modules(result_options) end # Evaluate @@ -69,11 +66,9 @@ def self.evaluate_with_hooks(options) evaluation[:variable_value] = options[:default_variable_value] end - # Run after hooks - hooks.each do |hook| - if hook.respond_to?(:call_after) - evaluation = hook.call_after(evaluation, result_options) - end + # Run after modules + if modules_manager + evaluation = modules_manager.run_after_modules(evaluation, result_options) end evaluation @@ -108,9 +103,7 @@ def self.evaluate(options) logger = options[:logger] datafile_reader = options[:datafile_reader] sticky = options[:sticky] - hooks_manager = options[:hooks_manager] - - hooks = hooks_manager.get_all + modules_manager = options[:modules_manager] evaluation = nil begin @@ -422,24 +415,24 @@ def self.evaluate(options) logger: logger }) - # Run bucket key hooks - bucket_key = hooks_manager.run_bucket_key_hooks({ + # Run bucket key modules + bucket_key = modules_manager.run_bucket_key_modules({ feature_key: feature_key, context: context, bucket_by: feature[:bucketBy], bucket_key: bucket_key - }) + }) if modules_manager # bucketValue bucket_value = Featurevisor::Bucketer.get_bucketed_number(bucket_key) - # Run bucket value hooks - bucket_value = hooks_manager.run_bucket_value_hooks({ + # Run bucket value modules + bucket_value = modules_manager.run_bucket_value_modules({ feature_key: feature_key, bucket_key: bucket_key, context: context, bucket_value: bucket_value - }) + }) if modules_manager matched_traffic = nil matched_allocation = nil diff --git a/lib/featurevisor/events.rb b/lib/featurevisor/events.rb index bf0e89b..511324b 100644 --- a/lib/featurevisor/events.rb +++ b/lib/featurevisor/events.rb @@ -24,7 +24,7 @@ def self.get_params_for_sticky_set_event(previous_sticky = {}, new_sticky = {}, # @param previous_reader [DatafileReader] Previous datafile reader # @param new_reader [DatafileReader] New datafile reader # @return [Hash] Event parameters - def self.get_params_for_datafile_set_event(previous_reader, new_reader) + def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false) previous_revision = previous_reader.get_revision previous_feature_keys = previous_reader.get_feature_keys @@ -67,9 +67,10 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader) { revision: new_revision, - previous_revision: previous_revision, - revision_changed: previous_revision != new_revision, - features: all_affected_features + previousRevision: previous_revision, + revisionChanged: previous_revision != new_revision, + features: all_affected_features, + replaced: replace } end end diff --git a/lib/featurevisor/hooks.rb b/lib/featurevisor/hooks.rb deleted file mode 100644 index 8e85702..0000000 --- a/lib/featurevisor/hooks.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -module Featurevisor - # Hooks module for extending evaluation behavior - module Hooks - # Hook interface for extending evaluation behavior - class Hook - attr_reader :name - - # Initialize a new hook - # @param options [Hash] Hook options - # @option options [String] :name Hook name - # @option options [Proc, nil] :before Before evaluation hook - # @option options [Proc, nil] :bucket_key Bucket key configuration hook - # @option options [Proc, nil] :bucket_value Bucket value configuration hook - # @option options [Proc, nil] :after After evaluation hook - def initialize(options) - @name = options[:name] - @before = options[:before] - @bucket_key = options[:bucket_key] - @bucket_value = options[:bucket_value] - @after = options[:after] - end - - # Call the before hook if defined - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation options - def call_before(options) - return options unless @before - - @before.call(options) - end - - # Call the bucket key hook if defined - # @param options [Hash] Bucket key options - # @return [String] Modified bucket key - def call_bucket_key(options) - return options[:bucket_key] unless @bucket_key - - @bucket_key.call(options) - end - - # Call the bucket value hook if defined - # @param options [Hash] Bucket value options - # @return [Integer] Modified bucket value - def call_bucket_value(options) - return options[:bucket_value] unless @bucket_value - - @bucket_value.call(options) - end - - # Call the after hook if defined - # @param evaluation [Hash] Evaluation result - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation result - def call_after(evaluation, options) - return evaluation unless @after - - @after.call(evaluation, options) - end - end - - # HooksManager class for managing hooks - class HooksManager - attr_reader :hooks, :logger - - # Initialize a new HooksManager - # @param options [Hash] Options hash containing hooks and logger - # @option options [Array] :hooks Array of hooks - # @option options [Logger] :logger Logger instance - def initialize(options) - @logger = options[:logger] - @hooks = [] - - if options[:hooks] - options[:hooks].each do |hook| - add(hook) - end - end - end - - # Add a hook to the manager - # @param hook [Hook] Hook to add - # @return [Proc, nil] Remove function or nil if hook already exists - def add(hook) - if @hooks.any? { |existing_hook| existing_hook.name == hook.name } - @logger.error("Hook with name \"#{hook.name}\" already exists.", { - name: hook.name, - hook: hook - }) - - return nil - end - - @hooks << hook - - # Return a remove function - -> { remove(hook.name) } - end - - # Remove a hook by name - # @param name [String] Hook name to remove - def remove(name) - @hooks = @hooks.reject { |hook| hook.name == name } - end - - # Get all hooks - # @return [Array] Array of all hooks - def get_all - @hooks - end - - # Run before hooks - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation options - def run_before_hooks(options) - result = options - @hooks.each do |hook| - result = hook.call_before(result) - end - result - end - - # Run bucket key hooks - # @param options [Hash] Bucket key options - # @return [String] Modified bucket key - def run_bucket_key_hooks(options) - bucket_key = options[:bucket_key] - @hooks.each do |hook| - bucket_key = hook.call_bucket_key(options.merge(bucket_key: bucket_key)) - end - bucket_key - end - - # Run bucket value hooks - # @param options [Hash] Bucket value options - # @return [Integer] Modified bucket value - def run_bucket_value_hooks(options) - bucket_value = options[:bucket_value] - @hooks.each do |hook| - bucket_value = hook.call_bucket_value(options.merge(bucket_value: bucket_value)) - end - bucket_value - end - - # Run after hooks - # @param evaluation [Hash] Evaluation result - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation result - def run_after_hooks(evaluation, options) - result = evaluation - @hooks.each do |hook| - result = hook.call_after(result, options) - end - result - end - end - end -end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 3e8a66d..705a7e2 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true require "json" +require "securerandom" module Featurevisor # Instance class for managing feature flag evaluations class Instance - attr_reader :context, :logger, :sticky, :datafile_reader, :hooks_manager, :emitter - # Empty datafile template EMPTY_DATAFILE = { schemaVersion: "2", @@ -20,34 +19,44 @@ class Instance # @option options [Hash, String] :datafile Datafile content or JSON string # @option options [Hash] :context Initial context # @option options [String] :log_level Log level - # @option options [Logger] :logger Logger instance # @option options [Hash] :sticky Sticky features - # @option options [Array] :hooks Array of hooks + # @option options [Array] :modules Array of modules + # @option options [Proc] :on_diagnostic Diagnostic handler def initialize(options = {}) # from options @context = options[:context] || {} - @logger = options[:logger] || Featurevisor.create_logger(level: options[:log_level] || "info") - @hooks_manager = Featurevisor::Hooks::HooksManager.new( - hooks: (options[:hooks] || []).map { |hook_data| Featurevisor::Hooks::Hook.new(hook_data) }, - logger: @logger + @logger = Logger.new( + level: options[:log_level] || "info", + handler: method(:handle_internal_log) ) + @on_diagnostic = options[:on_diagnostic] || options[:onDiagnostic] @emitter = Featurevisor::Emitter.new @sticky = options[:sticky] || {} + @closed = false + @module_diagnostic_subscriptions = [] # datafile - @datafile_reader = Featurevisor::DatafileReader.new( + @datafile_reader = DatafileReader.new( datafile: EMPTY_DATAFILE, logger: @logger ) + @modules_manager = Featurevisor::Modules::ModulesManager.new( + modules: options[:modules] || [], + report_diagnostic: method(:report_diagnostic), + module_api_factory: method(:create_module_api), + clear_module_diagnostic_subscriptions: method(:clear_module_diagnostic_subscriptions) + ) + if options[:datafile] - @datafile_reader = Featurevisor::DatafileReader.new( - datafile: options[:datafile].is_a?(String) ? JSON.parse(options[:datafile], symbolize_names: true) : options[:datafile], - logger: @logger - ) + set_datafile(options[:datafile], true) end - @logger.info("Featurevisor SDK initialized") + report_diagnostic( + level: "info", + code: "sdk_initialized", + message: "SDK initialized" + ) end # Set the log level @@ -56,22 +65,61 @@ def set_log_level(level) @logger.set_level(level) end + def handle_internal_log(level, message, details = nil) + details = (details || {}).dup + code = details[:reason] || details["reason"] || message + code = "deprecated_feature" if message == "feature is deprecated" + code = "deprecated_variable" if message == "variable is deprecated" + code = "feature_not_found" if message == "feature not found" + code = "variable_not_found" if message == "variable schema not found" + code = "no_variations" if message == "no variations" + code = "invalid_bucket_by" if message == "invalid bucketBy" + report_diagnostic(level: level, code: code.to_s, message: message, details: details) + end + private :handle_internal_log + # Set the datafile # @param datafile [Hash, String] Datafile content or JSON string - def set_datafile(datafile) + # @param replace [Boolean] Whether to replace instead of merge + def set_datafile(datafile, replace = false) + return if @closed + begin - new_datafile_reader = Featurevisor::DatafileReader.new( - datafile: datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile, + parsed_datafile = if datafile.is_a?(String) + JSON.parse(datafile, symbolize_names: true) + elsif datafile.is_a?(Hash) + JSON.parse(JSON.generate(datafile), symbolize_names: true) + else + datafile + end + unless parsed_datafile.is_a?(Hash) && parsed_datafile[:schemaVersion].is_a?(String) && + parsed_datafile[:revision].is_a?(String) && parsed_datafile[:segments].is_a?(Hash) && + parsed_datafile[:features].is_a?(Hash) + raise ArgumentError, "Invalid datafile" + end + next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile_reader.get_datafile, parsed_datafile) + new_datafile_reader = DatafileReader.new( + datafile: next_datafile, logger: @logger ) - details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader) + details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader, replace) @datafile_reader = new_datafile_reader - @logger.info("datafile set", details) + report_diagnostic( + level: "info", + code: "datafile_set", + message: "Datafile set", + details: details + ) @emitter.trigger("datafile_set", details) rescue => e - @logger.error("could not parse datafile", { error: e }) + report_diagnostic( + level: "error", + code: "invalid_datafile", + message: "Could not parse datafile", + original_error: e + ) end end @@ -92,7 +140,12 @@ def set_sticky(sticky, replace = false) params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace) - @logger.info("sticky features set", params) + report_diagnostic( + level: "info", + code: "sticky_set", + message: "Sticky features set", + details: params + ) @emitter.trigger("sticky_set", params) end @@ -102,6 +155,26 @@ def get_revision @datafile_reader.get_revision end + def get_schema_version + @datafile_reader.get_schema_version + end + + def get_segment(segment_key) + @datafile_reader.get_segment(segment_key) + end + + def get_feature_keys + @datafile_reader.get_feature_keys + end + + def get_variable_keys(feature_key) + @datafile_reader.get_variable_keys(feature_key) + end + + def has_variations?(feature_key) + @datafile_reader.has_variations?(feature_key) + end + # Get a feature by key # @param feature_key [String] Feature key # @return [Hash, nil] Feature data or nil if not found @@ -109,11 +182,15 @@ def get_feature(feature_key) @datafile_reader.get_feature(feature_key) end - # Add a hook - # @param hook [Hook] Hook to add - # @return [Proc, nil] Remove function or nil if hook already exists - def add_hook(hook) - @hooks_manager.add(hook) + # Add a module + # @param mod [Hash, FeaturevisorModule] Module to add + # @return [Proc, nil] Remove function or nil if module already exists + def add_module(mod) + @modules_manager.add(mod) + end + + def remove_module(name_or_module) + @modules_manager.remove(name_or_module) end # Subscribe to an event @@ -126,6 +203,9 @@ def on(event_name, callback) # Close the instance def close + @closed = true + @modules_manager.close_all + @module_diagnostic_subscriptions = [] @emitter.clear_all end @@ -144,10 +224,15 @@ def set_context(context, replace = false) replaced: replace }) - @logger.debug(replace ? "context replaced" : "context updated", { - context: @context, - replaced: replace - }) + report_diagnostic( + level: "debug", + code: "context_set", + message: replace ? "Context replaced" : "Context updated", + details: { + context: @context, + replaced: replace + } + ) end # Get context @@ -182,7 +267,7 @@ def spawn(context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_flag(feature_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "flag", feature_key: feature_key @@ -211,7 +296,7 @@ def is_enabled(feature_key, context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_variation(feature_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "variation", feature_key: feature_key @@ -248,7 +333,7 @@ def get_variation(feature_key, context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_variable(feature_key, variable_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "variable", feature_key: feature_key, @@ -417,9 +502,9 @@ def get_evaluation_dependencies(context, options = {}) { context: get_context(context), logger: @logger, - hooks_manager: @hooks_manager, + modules_manager: @modules_manager, datafile_reader: @datafile_reader, - sticky: options[:sticky] ? { **(@sticky || {}), **options[:sticky] } : @sticky, + sticky: options[:__featurevisor_child_sticky] || @sticky, default_variation_value: options[:default_variation_value], default_variable_value: options[:default_variable_value] } @@ -436,11 +521,12 @@ def get_value_by_type(value, type) when "string" value.is_a?(String) ? value : nil when "integer" - value.is_a?(String) ? Integer(value, 10) : (value.is_a?(Integer) ? value : nil) + return value if value.is_a?(Integer) + value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : nil when "double" - value.is_a?(String) ? Float(value) : (value.is_a?(Numeric) ? value.to_f : nil) + value.is_a?(Numeric) && value.finite? ? value.to_f : nil when "boolean" - value == true + value == true || value == false ? value : nil when "array" value.is_a?(Array) ? value : nil when "object" @@ -452,12 +538,112 @@ def get_value_by_type(value, type) rescue nil end + + def merge_datafiles(previous, incoming) + previous ||= EMPTY_DATAFILE + incoming ||= EMPTY_DATAFILE + + { + schemaVersion: incoming[:schemaVersion], + revision: incoming[:revision], + featurevisorVersion: incoming[:featurevisorVersion], + segments: { + **(previous[:segments] || {}), + **(incoming[:segments] || {}) + }, + features: { + **(previous[:features] || {}), + **(incoming[:features] || {}) + } + }.compact + end + + def create_module_api(mod) + instance = self + { + get_revision: -> { instance.get_revision }, + on_diagnostic: lambda do |handler, options = {}| + subscription = { + id: SecureRandom.uuid, + module_id: mod.id, + handler: handler, + level: options[:level] || options[:log_level] || "info" + } + @module_diagnostic_subscriptions << subscription + -> { @module_diagnostic_subscriptions.reject! { |item| item[:id] == subscription[:id] } } + end, + report_diagnostic: ->(diagnostic) { report_diagnostic(diagnostic, mod) } + } + end + + def clear_module_diagnostic_subscriptions(mod) + @module_diagnostic_subscriptions.reject! { |item| item[:module_id] == mod.id } + end + + def report_diagnostic(diagnostic, source_module = nil) + diagnostic = (diagnostic || {}).dup + diagnostic[:level] ||= "info" + diagnostic[:module] = source_module.name if source_module && source_module.name + details = (diagnostic[:details] || {}).dup + legacy_module_name = diagnostic.delete(:module_name) + legacy_original_error = diagnostic.delete(:original_error) + diagnostic[:moduleName] = legacy_module_name if !diagnostic.key?(:moduleName) && !legacy_module_name.nil? + diagnostic[:originalError] = legacy_original_error if !diagnostic.key?(:originalError) && !legacy_original_error.nil? + diagnostic.each do |key, value| + next if %i[level code message module moduleName originalError details].include?(key) + + details[key] = value + end + diagnostic.select! { |key, _| %i[level code message module moduleName originalError details].include?(key) } + diagnostic[:details] = details + + @module_diagnostic_subscriptions.dup.each do |subscription| + next if source_module && subscription[:module_id] == source_module.id + next unless should_report_diagnostic?(diagnostic[:level], subscription[:level]) + + begin + subscription[:handler].call(diagnostic) + rescue => e + Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}") + end + end + + if @on_diagnostic + if should_report_diagnostic?(diagnostic[:level], @logger.level) + begin + @on_diagnostic.call(diagnostic) + rescue => e + Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}") + end + end + else + Logger.new(level: @logger.level).log( + diagnostic[:level], + diagnostic[:message], + diagnostic + ) + end + + if diagnostic[:level] == "error" + @emitter.trigger("error", diagnostic: diagnostic) + end + end + + def should_report_diagnostic?(diagnostic_level, subscriber_level) + levels = Featurevisor::LOG_LEVELS + diagnostic_index = levels.index(diagnostic_level || "info") + subscriber_index = levels.index(subscriber_level || "info") + + return false if diagnostic_index.nil? || subscriber_index.nil? + + subscriber_index >= diagnostic_index + end end # Create a new Featurevisor instance # @param options [Hash] Instance options # @return [Instance] New instance - def self.create_instance(options = {}) + def self.create_featurevisor(options = {}) Instance.new(options) end end diff --git a/lib/featurevisor/logger.rb b/lib/featurevisor/logger.rb index b0a5c07..d76a6b3 100644 --- a/lib/featurevisor/logger.rb +++ b/lib/featurevisor/logger.rb @@ -113,13 +113,6 @@ def default_log_handler(level, message, details = nil) end end - # Create a new logger instance - # @param options [Hash] Logger options - # @return [Logger] New logger instance - def self.create_logger(options = {}) - Logger.new(options) - end - # Default log handler function # @param level [String] Log level # @param message [String] Log message diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb new file mode 100644 index 0000000..650eceb --- /dev/null +++ b/lib/featurevisor/modules.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "securerandom" + +module Featurevisor + # Modules extend evaluation behavior and SDK lifecycle. + module Modules + class FeaturevisorModule + attr_reader :id, :name + + def initialize(options = {}) + @id = SecureRandom.uuid + @name = options[:name] + @setup = options[:setup] + @before = options[:before] + @bucket_key = options[:bucket_key] + @bucket_value = options[:bucket_value] + @after = options[:after] + @close = options[:close] + end + + def call_setup(api) + @setup.call(api) if @setup + end + + def call_before(options) + return options unless @before + + @before.call(options) + end + + def call_bucket_key(options) + return options[:bucket_key] unless @bucket_key + + @bucket_key.call(options) + end + + def call_bucket_value(options) + return options[:bucket_value] unless @bucket_value + + @bucket_value.call(options) + end + + def call_after(evaluation, options) + return evaluation unless @after + + @after.call(evaluation, options) + end + + def call_close + @close.call if @close + end + end + + class ModulesManager + attr_reader :modules + + def initialize(options = {}) + @modules = [] + @report_diagnostic = options[:report_diagnostic] + @module_api_factory = options[:module_api_factory] + @clear_module_diagnostic_subscriptions = options[:clear_module_diagnostic_subscriptions] + + (options[:modules] || []).each do |mod| + add(mod) + end + end + + def add(mod) + mod = FeaturevisorModule.new(mod) if mod.is_a?(Hash) + return nil unless mod + + if mod.name && !mod.name.to_s.empty? && @modules.any? { |existing| existing.name == mod.name } + report( + { + level: "error", + code: "duplicate_module", + message: "Duplicate module name", + module_name: mod.name + }, + nil + ) + return nil + end + + begin + mod.call_setup(@module_api_factory.call(mod)) if @module_api_factory + rescue => e + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + report( + { + level: "error", + code: "module_setup_error", + message: "Module setup failed", + module_name: mod.name, + original_error: e + }, + nil + ) + close_module(mod) + return nil + end + @modules << mod + + -> { remove(mod) } + end + + def remove(name_or_module) + removed = [] + @modules = @modules.reject do |mod| + matches = name_or_module.equal?(mod) || mod.name == name_or_module + removed << mod if matches + matches + end + + removed.each do |mod| + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + close_module(mod) + end + end + + def get_all + @modules + end + + def run_before_modules(options) + @modules.reduce(options) do |result, mod| + mod.call_before(result) + end + end + + def run_bucket_key_modules(options) + bucket_key = options[:bucket_key] + @modules.each do |mod| + bucket_key = mod.call_bucket_key(options.merge(bucket_key: bucket_key)) + end + bucket_key + end + + def run_bucket_value_modules(options) + bucket_value = options[:bucket_value] + @modules.each do |mod| + bucket_value = mod.call_bucket_value(options.merge(bucket_value: bucket_value)) + end + bucket_value + end + + def run_after_modules(evaluation, options) + @modules.reduce(evaluation) do |result, mod| + mod.call_after(result, options) + end + end + + def close_all + @modules.each do |mod| + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + close_module(mod) + end + @modules = [] + end + + private + + def close_module(mod) + mod.call_close + rescue => e + report( + { + level: "error", + code: "module_close_error", + message: "Module close failed", + module_name: mod.name, + original_error: e + }, + nil + ) + end + + def report(diagnostic, mod) + @report_diagnostic.call(diagnostic, mod) if @report_diagnostic + end + end + end +end diff --git a/lib/featurevisor/version.rb b/lib/featurevisor/version.rb index ddf7ce1..90d5e0f 100644 --- a/lib/featurevisor/version.rb +++ b/lib/featurevisor/version.rb @@ -1,3 +1,3 @@ module Featurevisor - VERSION = "0.3.0" + VERSION = "1.0.0" end diff --git a/spec/bucketer_spec.rb b/spec/bucketer_spec.rb index 1627b78..7caf762 100644 --- a/spec/bucketer_spec.rb +++ b/spec/bucketer_spec.rb @@ -38,7 +38,7 @@ end describe "get_bucket_key" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } it "should be a method" do expect(Featurevisor::Bucketer).to respond_to(:get_bucket_key) diff --git a/spec/child_instance_spec.rb b/spec/child_instance_spec.rb index a433c37..146a948 100644 --- a/spec/child_instance_spec.rb +++ b/spec/child_instance_spec.rb @@ -2,7 +2,7 @@ RSpec.describe "sdk: child" do it "should create a child instance" do - f = Featurevisor.create_instance( + f = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 643de6e..cccd688 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -38,6 +38,11 @@ end RSpec.describe FeaturevisorCLI::Parser do + it "parses and deduplicates repeated targets" do + options = described_class.parse(["test", "--target=web", "--target=mobile", "--target=web"]) + expect(options.targets).to eq(["web", "mobile"]) + end + describe ".parse" do it "parses basic options" do options = FeaturevisorCLI::Parser.parse(["test", "--verbose", "--n=5000", "--with-scopes", "--with-tags"]) @@ -48,12 +53,20 @@ expect(options.with_tags).to be true end - it "parses camelCase aliases for scope/tag flags" do + it "parses legacy compatibility 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 "accepts legacy schema version aliases" do + kebab_options = FeaturevisorCLI::Parser.parse(["test", "--schema-version=2"]) + camel_options = FeaturevisorCLI::Parser.parse(["test", "--schemaVersion=2"]) + + expect(kebab_options.schema_version).to eq("2") + expect(camel_options.schema_version).to eq("2") + end + it "sets default values" do options = FeaturevisorCLI::Parser.parse(["test"]) expect(options.n).to eq(1000) @@ -101,6 +114,9 @@ it "accepts valid parameters" do # Mock the build_datafile method to avoid external command execution allow_any_instance_of(FeaturevisorCLI::Commands::Benchmark).to receive(:build_datafile).and_return({ + "schemaVersion" => "2", + "revision" => "test", + "segments" => {}, "features" => { "testFeature" => { "key" => "testFeature", diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 80eac3f..9472e51 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -1,9 +1,9 @@ require "featurevisor" RSpec.describe Featurevisor::Conditions do - let(:logger) { Featurevisor.create_logger } + let(:logger) { Featurevisor.const_get(:Logger).new } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -428,6 +428,25 @@ ).to be false end + it "should treat empty NOT as false and nested OR under NOT as none-match" do + none_of_chrome_or_firefox = [ + { + not: [ + { + or: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { attribute: "browser_type", operator: "equals", value: "firefox" } + ] + } + ] + } + ] + + expect(datafile_reader.all_conditions_are_matched(none_of_chrome_or_firefox, { browser_type: "chrome" })).to be false + expect(datafile_reader.all_conditions_are_matched(none_of_chrome_or_firefox, { browser_type: "edge" })).to be true + expect(datafile_reader.all_conditions_are_matched([{ not: [] }], {})).to be false + end + it "should match with OR inside AND" do conditions = [ { diff --git a/spec/conformance_spec.rb b/spec/conformance_spec.rb new file mode 100644 index 0000000..742a5e6 --- /dev/null +++ b/spec/conformance_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "Featurevisor v3 conformance" do + it "uses the shared inclusive allocation contract" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + reader = Featurevisor.const_get(:DatafileReader).new( + datafile: { schemaVersion: "2", revision: "conformance", segments: {}, features: {} }, + logger: Featurevisor.const_get(:Logger).new(level: "fatal") + ) + traffic = { allocation: fixture.dig(:bucketing, :allocations) } + + fixture.dig(:bucketing, :allocationExpectations).each do |bucket, expected| + allocation = reader.get_matched_allocation(traffic, bucket.to_s.to_i) + expect(allocation[:variation]).to eq(expected) + end + end +end diff --git a/spec/datafile_reader_spec.rb b/spec/datafile_reader_spec.rb index 2fdbf0e..071ae68 100644 --- a/spec/datafile_reader_spec.rb +++ b/spec/datafile_reader_spec.rb @@ -1,18 +1,18 @@ require "featurevisor" -RSpec.describe Featurevisor::DatafileReader do - let(:logger) { Featurevisor.create_logger } +RSpec.describe Featurevisor.const_get(:DatafileReader) do + let(:logger) { Featurevisor.const_get(:Logger).new } describe "basic functionality" do it "should be a class" do - expect(Featurevisor::DatafileReader).to be_a(Class) + expect(Featurevisor.const_get(:DatafileReader)).to be_a(Class) end it "should create an instance with options" do datafile = { schemaVersion: "2", revision: "1", segments: {}, features: {} } - reader = Featurevisor::DatafileReader.new(datafile: datafile, logger: logger) + reader = Featurevisor.const_get(:DatafileReader).new(datafile: datafile, logger: logger) - expect(reader).to be_instance_of(Featurevisor::DatafileReader) + expect(reader).to be_instance_of(Featurevisor.const_get(:DatafileReader)) end end @@ -72,7 +72,7 @@ } end - let(:reader) { Featurevisor::DatafileReader.new(datafile: datafile_json, logger: logger) } + let(:reader) { Featurevisor.const_get(:DatafileReader).new(datafile: datafile_json, logger: logger) } it "should return requested entities" do expect(reader.get_revision).to eq("1") @@ -255,7 +255,7 @@ } end - let(:datafile_reader) { Featurevisor::DatafileReader.new(datafile: datafile_content, logger: logger) } + let(:datafile_reader) { Featurevisor.const_get(:DatafileReader).new(datafile: datafile_content, logger: logger) } it "should match everyone" do group = groups.find { |g| g[:key] == "*" } @@ -416,11 +416,32 @@ expect(datafile_reader.all_segments_are_matched(group[:segments], { version: "5.5" })).to be false expect(datafile_reader.all_segments_are_matched(group[:segments], { version: 5.5 })).to be false end + + it "should treat NOT segment children as an implicit AND" do + segments = { not: ["mobileUsers", "netherlands"] } + + expect(datafile_reader.all_segments_are_matched(segments, { + country: "nl", + deviceType: "mobile" + })).to be false + expect(datafile_reader.all_segments_are_matched(segments, { + country: "nl", + deviceType: "desktop" + })).to be true + end + + it "should treat empty NOT segments as false and nested OR as none-match" do + segments = { not: [{ or: ["mobileUsers", "desktopUsers"] }] } + + expect(datafile_reader.all_segments_are_matched(segments, { deviceType: "mobile" })).to be false + expect(datafile_reader.all_segments_are_matched(segments, { deviceType: "tv" })).to be true + expect(datafile_reader.all_segments_are_matched({ not: [] }, {})).to be false + end end describe "conditions" do let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -649,7 +670,7 @@ describe "utility methods" do let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", diff --git a/spec/emitter_spec.rb b/spec/emitter_spec.rb index 4fad618..cff1516 100644 --- a/spec/emitter_spec.rb +++ b/spec/emitter_spec.rb @@ -81,6 +81,24 @@ expect(handled_details.length).to eq(1) expect(handled_details[0]).to eq(complex_details) end + + it "should trigger a snapshot of listeners when listeners unsubscribe during dispatch" do + calls = [] + unsubscribe_second = nil + + emitter.on("sticky_set", ->(_details) { + calls << "first" + unsubscribe_second.call + }) + unsubscribe_second = emitter.on("sticky_set", ->(_details) { + calls << "second" + }) + + emitter.trigger("sticky_set") + emitter.trigger("sticky_set") + + expect(calls).to eq(%w[first second first]) + end end describe "unsubscribe functionality" do @@ -172,7 +190,7 @@ describe "constants" do it "should have correct event names" do - expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_set]) + expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_set error]) end end diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index 53e373e..3eb251c 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -37,10 +37,10 @@ end end - describe "evaluate_with_hooks" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + describe "evaluate_with_modules" do + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -50,10 +50,10 @@ logger: logger ) end - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } + let(:modules_manager) { Featurevisor::Modules::ModulesManager.new(logger: logger) } it "should be a method" do - expect(Featurevisor::Evaluate).to respond_to(:evaluate_with_hooks) + expect(Featurevisor::Evaluate).to respond_to(:evaluate_with_modules) end it "should handle errors gracefully" do @@ -62,14 +62,14 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } # Mock datafile_reader to raise an error allow(datafile_reader).to receive(:get_feature).and_raise(StandardError.new("Test error")) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::ERROR) expect(result[:error]).to be_a(StandardError) @@ -77,18 +77,18 @@ end it "should apply default variation value when specified" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(hook_applied: true) } + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-module", + after: ->(eval, opts) { eval.merge(module_applied: true) } ) - hooks_manager.add(hook) + modules_manager.add(mod) options = { type: "variation", feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, default_variation_value: "default" } @@ -96,18 +96,18 @@ # Mock datafile_reader to return no feature allow(datafile_reader).to receive(:get_feature).and_return(nil) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::FEATURE_NOT_FOUND) - expect(result[:hook_applied]).to be true + expect(result[:module_applied]).to be true end it "should apply default variable value when specified" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(hook_applied: true) } + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-module", + after: ->(eval, opts) { eval.merge(module_applied: true) } ) - hooks_manager.add(hook) + modules_manager.add(mod) options = { type: "variable", @@ -115,7 +115,7 @@ variable_key: "test-var", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, default_variable_value: "default" } @@ -123,17 +123,17 @@ # Mock datafile_reader to return no feature allow(datafile_reader).to receive(:get_feature).and_return(nil) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::FEATURE_NOT_FOUND) - expect(result[:hook_applied]).to be true + expect(result[:module_applied]).to be true end end describe "evaluate" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -143,7 +143,7 @@ logger: logger ) end - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } + let(:modules_manager) { Featurevisor::Modules::ModulesManager.new(logger: logger) } it "should be a method" do expect(Featurevisor::Evaluate).to respond_to(:evaluate) @@ -155,7 +155,7 @@ feature_key: "non-existent-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -177,7 +177,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -201,7 +201,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -227,7 +227,7 @@ variable_key: "test-var", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -260,7 +260,7 @@ feature_key: feature, context: { userId: "123" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -298,7 +298,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -355,7 +355,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -398,7 +398,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -411,7 +411,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -471,7 +471,7 @@ variable_key: :test_var, context: { country: "nl" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -533,7 +533,7 @@ variable_key: :test_var, context: { country: "nl" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) diff --git a/spec/events_spec.rb b/spec/events_spec.rb index 06987b3..090741f 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -1,7 +1,7 @@ require "featurevisor" RSpec.describe Featurevisor::Events do - let(:logger) { Featurevisor.create_logger(level: "error") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "error") } describe ".get_params_for_sticky_set_event" do it "should get params for sticky set event: empty to new" do @@ -50,7 +50,7 @@ describe ".get_params_for_datafile_set_event" do def build_reader(revision:, features:) - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "1.0.0", revision: revision, @@ -75,9 +75,10 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, - features: %i[feature1 feature2] + previousRevision: "1", + revisionChanged: true, + features: %i[feature1 feature2], + replaced: false }) end @@ -102,9 +103,10 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, - features: %i[feature2 feature3] + previousRevision: "1", + revisionChanged: true, + features: %i[feature2 feature3], + replaced: false }) end @@ -127,9 +129,10 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, - features: %i[feature1 feature2] + previousRevision: "1", + revisionChanged: true, + features: %i[feature1 feature2], + replaced: false }) end end diff --git a/spec/hooks_spec.rb b/spec/hooks_spec.rb deleted file mode 100644 index ef2af95..0000000 --- a/spec/hooks_spec.rb +++ /dev/null @@ -1,236 +0,0 @@ -require "featurevisor" - -RSpec.describe Featurevisor::Hooks do - describe "Hook" do - let(:logger) { Featurevisor.create_logger(level: "warn") } - - it "should be a class" do - expect(Featurevisor::Hooks::Hook).to be_a(Class) - end - - it "should initialize with options" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - before: ->(opts) { opts.merge(test: true) }, - after: ->(eval, opts) { eval.merge(test: true) } - ) - - expect(hook.name).to eq("test-hook") - expect(hook).to respond_to(:call_before) - expect(hook).to respond_to(:call_after) - end - - it "should call before hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - before: ->(opts) { opts.merge(test: true) } - ) - - result = hook.call_before({ original: true }) - expect(result[:test]).to be true - expect(result[:original]).to be true - end - - it "should return original options when before hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_before({ original: true }) - expect(result[:original]).to be true - expect(result[:test]).to be_nil - end - - it "should call bucket key hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } - ) - - result = hook.call_bucket_key({ bucket_key: "original" }) - expect(result).to eq("modified-original") - end - - it "should return original bucket key when bucket key hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_bucket_key({ bucket_key: "original" }) - expect(result).to eq("original") - end - - it "should call bucket value hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_value: ->(opts) { opts[:bucket_value] * 2 } - ) - - result = hook.call_bucket_value({ bucket_value: 50 }) - expect(result).to eq(100) - end - - it "should return original bucket value when bucket value hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_bucket_value({ bucket_value: 50 }) - expect(result).to eq(50) - end - - it "should call after hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(test: true) } - ) - - result = hook.call_after({ original: true }, { options: true }) - expect(result[:test]).to be true - expect(result[:original]).to be true - end - - it "should return original evaluation when after hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_after({ original: true }, { options: true }) - expect(result[:original]).to be true - expect(result[:test]).to be_nil - end - end - - describe "HooksManager" do - let(:logger) { Featurevisor.create_logger(level: "warn") } - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } - - it "should be a class" do - expect(Featurevisor::Hooks::HooksManager).to be_a(Class) - end - - it "should initialize with options" do - expect(hooks_manager.logger).to eq(logger) - expect(hooks_manager.hooks).to eq([]) - end - - it "should add hooks" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - remove_fn = hooks_manager.add(hook) - - expect(hooks_manager.hooks).to include(hook) - expect(remove_fn).to be_a(Proc) - end - - it "should not add duplicate hooks" do - hook1 = Featurevisor::Hooks::Hook.new(name: "test-hook") - hook2 = Featurevisor::Hooks::Hook.new(name: "test-hook") - - hooks_manager.add(hook1) - result = hooks_manager.add(hook2) - - expect(hooks_manager.hooks).to eq([hook1]) - expect(result).to be_nil - end - - it "should remove hooks by name" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - hooks_manager.add(hook) - - hooks_manager.remove("test-hook") - expect(hooks_manager.hooks).to be_empty - end - - it "should get all hooks" do - hook1 = Featurevisor::Hooks::Hook.new(name: "hook1") - hook2 = Featurevisor::Hooks::Hook.new(name: "hook2") - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - expect(hooks_manager.get_all).to eq([hook1, hook2]) - end - - it "should run before hooks" do - hook1 = Featurevisor::Hooks::Hook.new( - name: "hook1", - before: ->(opts) { opts.merge(hook1: true) } - ) - hook2 = Featurevisor::Hooks::Hook.new( - name: "hook2", - before: ->(opts) { opts.merge(hook2: true) } - ) - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - result = hooks_manager.run_before_hooks({ original: true }) - expect(result[:hook1]).to be true - expect(result[:hook2]).to be true - expect(result[:original]).to be true - end - - it "should run bucket key hooks" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } - ) - - hooks_manager.add(hook) - - result = hooks_manager.run_bucket_key_hooks({ - feature_key: "test", - context: {}, - bucket_by: "userId", - bucket_key: "original" - }) - - expect(result).to eq("modified-original") - end - - it "should run bucket value hooks" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_value: ->(opts) { opts[:bucket_value] * 2 } - ) - - hooks_manager.add(hook) - - result = hooks_manager.run_bucket_value_hooks({ - feature_key: "test", - bucket_key: "test.123", - context: {}, - bucket_value: 50 - }) - - expect(result).to eq(100) - end - - it "should run after hooks" do - hook1 = Featurevisor::Hooks::Hook.new( - name: "hook1", - after: ->(eval, opts) { eval.merge(hook1: true) } - ) - hook2 = Featurevisor::Hooks::Hook.new( - name: "hook2", - after: ->(eval, opts) { eval.merge(hook2: true) } - ) - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - result = hooks_manager.run_after_hooks({ original: true }, { options: true }) - expect(result[:hook1]).to be true - expect(result[:hook2]).to be true - expect(result[:original]).to be true - end - - it "should initialize with existing hooks" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - manager = Featurevisor::Hooks::HooksManager.new(hooks: [hook], logger: logger) - - expect(manager.hooks).to include(hook) - end - - it "should return remove function when adding hook" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - remove_fn = hooks_manager.add(hook) - - expect(remove_fn).to be_a(Proc) - remove_fn.call - expect(hooks_manager.hooks).to be_empty - end - end -end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 4cea4ca..2858aad 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -2,11 +2,13 @@ RSpec.describe "sdk: instance" do it "should be a function" do - expect(Featurevisor.respond_to?(:create_instance)).to be true + expect(Featurevisor.respond_to?(:create_featurevisor)).to be true + expect(Featurevisor.respond_to?(:create_instance)).to be false + expect(Featurevisor.respond_to?(:create_logger)).to be false end it "should create instance with datafile content" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -16,12 +18,29 @@ ) expect(sdk.respond_to?(:get_variation)).to be true + expect(sdk.get_schema_version).to eq("2") + expect(sdk.get_feature_keys).to eq([]) + end + + it "reports lifecycle mutation diagnostics" do + diagnostics = [] + sdk = Featurevisor.create_featurevisor( + log_level: "debug", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + + sdk.set_datafile(schemaVersion: "2", revision: "1", segments: {}, features: {}) + sdk.set_sticky(test: { enabled: true }) + sdk.set_context(country: "nl") + + codes = diagnostics.map { |diagnostic| diagnostic[:code] } + expect(codes).to include("datafile_set", "sticky_set", "context_set") end it "should configure plain bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -45,7 +64,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -69,7 +88,7 @@ it "should configure and bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -93,7 +112,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -117,7 +136,7 @@ it "should configure or bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -141,7 +160,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -175,12 +194,12 @@ expect(captured_bucket_key).to eq("456.test") end - it "should intercept context: before hook" do + it "should intercept context: before module" do intercepted = false intercepted_feature_key = "" intercepted_variable_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -204,7 +223,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", before: ->(options) { @@ -231,12 +250,12 @@ expect(intercepted_variable_key).to be_nil end - it "should intercept value: after hook" do + it "should intercept value: after module" do intercepted = false intercepted_feature_key = "" intercepted_variable_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -260,7 +279,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", after: ->(evaluation, options) { @@ -313,7 +332,7 @@ segments: {} } - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( sticky: { test: { enabled: true, @@ -357,7 +376,7 @@ end it "should honour simple required features" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -396,7 +415,7 @@ expect(sdk.is_enabled("myKey")).to be false # enabling required should enable the feature too - sdk2 = Featurevisor.create_instance( + sdk2 = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -435,7 +454,7 @@ it "should honour required features with variation" do # should be disabled because required has different variation - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -482,7 +501,7 @@ expect(sdk.is_enabled("myKey")).to be false # child should be enabled because required has desired variation - sdk2 = Featurevisor.create_instance( + sdk2 = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -531,7 +550,7 @@ it "should emit warnings for deprecated feature" do deprecated_count = 0 - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -572,13 +591,11 @@ }, segments: {} }, - logger: Featurevisor.create_logger( - handler: ->(level, message, details) { - if level == "warn" && message.include?("is deprecated") + on_diagnostic: ->(diagnostic) { + if diagnostic[:code] == "deprecated_feature" deprecated_count += 1 end } - ) ) test_variation = sdk.get_variation("test", { @@ -594,7 +611,7 @@ end it "should check if enabled for overridden flags from rules" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -641,8 +658,8 @@ it "should check if enabled for mutually exclusive features" do bucket_value = 10_000 - sdk = Featurevisor.create_instance( - hooks: [ + sdk = Featurevisor.create_featurevisor( + modules: [ { name: "unit-test", bucket_value: ->(options) { @@ -676,7 +693,7 @@ end it "should get variation" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -755,7 +772,7 @@ end it "should get variable" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1065,7 +1082,7 @@ end it "should get variables without any variations" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1135,7 +1152,7 @@ end it "should check if enabled for individually named segments" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1226,7 +1243,7 @@ "segments": {} }' - sdk = Featurevisor.create_instance(datafile: json_datafile) + sdk = Featurevisor.create_featurevisor(datafile: json_datafile) expect(sdk.get_revision).to eq("1.0") expect(sdk.get_feature("test")).to be_a(Hash) @@ -1237,7 +1254,7 @@ end it "should handle JSON string when setting datafile" do - sdk = Featurevisor.create_instance + sdk = Featurevisor.create_featurevisor json_datafile = '{ "schemaVersion": "2", @@ -1291,7 +1308,7 @@ # Parse with symbolize_names: true as documented datafile = JSON.parse(json_string, symbolize_names: true) - sdk = Featurevisor.create_instance(datafile: datafile) + sdk = Featurevisor.create_featurevisor(datafile: datafile) expect(sdk.get_revision).to eq("3.0") expect(sdk.get_feature("manualTest")).to be_a(Hash) @@ -1299,9 +1316,248 @@ expect(sdk.is_enabled("manualTest", { userId: "123" })).to be true end + it "should merge datafiles by default and preserve featurevisorVersion" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", + revision: "1.0", + featurevisorVersion: "3.0.0", + segments: {}, + features: { + firstFeature: { + key: "firstFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + sdk.set_datafile( + { + schemaVersion: "2", + revision: "2.0", + featurevisorVersion: "3.1.0", + segments: {}, + features: { + secondFeature: { + key: "secondFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + expect(sdk.get_revision).to eq("2.0") + reader = sdk.instance_variable_get(:@datafile_reader) + expect(reader.featurevisor_version).to eq("3.1.0") + expect(sdk.get_feature("firstFeature")).to be_a(Hash) + expect(sdk.get_feature("secondFeature")).to be_a(Hash) + end + + it "should replace datafile when replace is true" do + sdk = Featurevisor.create_featurevisor( + datafile: { + schemaVersion: "2", + revision: "1.0", + segments: {}, + features: { + firstFeature: { + key: "firstFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + sdk.set_datafile( + { + schemaVersion: "2", + revision: "2.0", + segments: {}, + features: { + secondFeature: { + key: "secondFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + }, + true + ) + + expect(sdk.get_feature("firstFeature")).to be_nil + expect(sdk.get_feature("secondFeature")).to be_a(Hash) + end + + it "should include replaced flag in datafile_set events" do + events = [] + sdk = Featurevisor.create_featurevisor + sdk.on("datafile_set", ->(event) { events << event }) + + sdk.set_datafile({ schemaVersion: "2", revision: "1.0", segments: {}, features: {} }) + sdk.set_datafile({ schemaVersion: "2", revision: "2.0", segments: {}, features: {} }, true) + + expect(events.map { |event| event[:replaced] }).to eq([false, true]) + end + + it "accepts a datafile hash with string keys" do + sdk = Featurevisor.create_featurevisor + sdk.set_datafile({ "schemaVersion" => "2", "revision" => "string-keys", "segments" => {}, "features" => {} }) + + expect(sdk.get_revision).to eq("string-keys") + end + + it "should run module setup and close lifecycle callbacks" do + setup_revision = nil + closed = false + + sdk = Featurevisor.create_featurevisor( + modules: [ + { + name: "lifecycle", + setup: ->(api) { setup_revision = api[:get_revision].call }, + close: -> { closed = true } + } + ] + ) + + expect(setup_revision).to eq("unknown") + + sdk.close + + expect(closed).to be true + end + + it "should report duplicate module diagnostics and emit error events" do + diagnostics = [] + errors = [] + sdk = Featurevisor.create_featurevisor( + log_level: "error", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, + modules: [{ name: "duplicate" }] + ) + sdk.on("error", ->(event) { errors << event }) + + result = sdk.add_module(name: "duplicate") + + expect(result).to be_nil + expect(diagnostics.last).to include( + level: "error", + code: "duplicate_module", + moduleName: "duplicate", + details: {} + ) + expect(errors.last).to include(diagnostic: include(code: "duplicate_module")) + end + + it "should report module close errors and keep closing remaining modules" do + diagnostics = [] + errors = [] + closed = [] + + sdk = Featurevisor.create_featurevisor( + log_level: "error", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, + modules: [ + { + name: "first", + close: -> { + closed << "first" + raise "first close failed" + } + }, + { + name: "second", + close: -> { closed << "second" } + } + ] + ) + sdk.on("error", ->(event) { errors << event }) + + sdk.close + + expect(closed).to eq(%w[first second]) + expect(diagnostics).to include( + include( + level: "error", + code: "module_close_error", + moduleName: "first", + originalError: be_a(RuntimeError), + details: {} + ) + ) + expect(errors).to include(include(diagnostic: include(code: "module_close_error", moduleName: "first"))) + end + + it "should report module close errors from unsubscribe once" do + diagnostics = [] + + sdk = Featurevisor.create_featurevisor( + log_level: "error", + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + + unsubscribe = sdk.add_module( + name: "dynamic", + close: -> { raise "dynamic close failed" } + ) + unsubscribe.call + unsubscribe.call + + expect(diagnostics.count { |diagnostic| diagnostic[:code] == "module_close_error" && diagnostic[:moduleName] == "dynamic" }).to eq(1) + end + + it "should support module diagnostic subscribe and report behavior" do + module_api = nil + received_by_instance = [] + received_by_module = [] + + sdk = Featurevisor.create_featurevisor( + on_diagnostic: ->(diagnostic) { received_by_instance << diagnostic }, + modules: [ + { + name: "listener", + setup: ->(api) { + api[:on_diagnostic].call(->(diagnostic) { received_by_module << diagnostic }) + } + }, + { + name: "reporter", + setup: ->(api) { module_api = api } + } + ] + ) + + module_api[:report_diagnostic].call(level: "warn", code: "module_warning", message: "module warning") + + expect(received_by_instance.last).to include(code: "module_warning", module: "reporter") + expect(received_by_module.last).to include(code: "module_warning", module: "reporter") + + sdk.remove_module("listener") + module_api[:report_diagnostic].call(level: "warn", code: "after_remove", message: "after remove") + + expect(received_by_instance.last).to include(code: "after_remove", module: "reporter") + expect(received_by_module.last).to include(code: "module_warning", module: "reporter") + end + + it "should isolate diagnostic handler failures" do + sdk = nil + + expect { + sdk = Featurevisor.create_featurevisor( + on_diagnostic: ->(_diagnostic) { raise "handler failed" } + ) + sdk.is_enabled("missing", {}) + sdk.close + }.not_to raise_error + end + describe "get_value_by_type" do let(:sdk) do - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1319,10 +1575,10 @@ expect(sdk.send(:get_value_by_type, "1", "string")).to eq("1") end - it "returns boolean coercion result for boolean type" do + it "returns booleans only 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 + expect(sdk.send(:get_value_by_type, "true", "boolean")).to be_nil end it "returns object value for object type" do @@ -1338,12 +1594,14 @@ 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) + it "returns integers only for integer type" do + expect(sdk.send(:get_value_by_type, 1, "integer")).to eq(1) + expect(sdk.send(:get_value_by_type, "1", "integer")).to be_nil end - it "parses double for double type" do - expect(sdk.send(:get_value_by_type, "1.1", "double")).to eq(1.1) + it "returns finite numbers only for double type" do + expect(sdk.send(:get_value_by_type, 1.1, "double")).to eq(1.1) + expect(sdk.send(:get_value_by_type, "1.1", "double")).to be_nil end it "returns nil when value is nil" do diff --git a/spec/logger_spec.rb b/spec/logger_spec.rb index a7a521d..d4ceb4e 100644 --- a/spec/logger_spec.rb +++ b/spec/logger_spec.rb @@ -1,6 +1,6 @@ require "featurevisor" -RSpec.describe Featurevisor::Logger do +RSpec.describe Featurevisor.const_get(:Logger) do let(:console_output) { StringIO.new } let(:original_stdout) { $stdout } let(:original_stderr) { $stderr } @@ -17,20 +17,20 @@ describe "create_logger" do it "should create a logger with default options" do - logger = Featurevisor.create_logger - expect(logger).to be_instance_of(Featurevisor::Logger) + logger = Featurevisor.const_get(:Logger).new + expect(logger).to be_instance_of(Featurevisor.const_get(:Logger)) end it "should create a logger with custom level" do - logger = Featurevisor.create_logger(level: "debug") - expect(logger).to be_instance_of(Featurevisor::Logger) + logger = Featurevisor.const_get(:Logger).new(level: "debug") + expect(logger).to be_instance_of(Featurevisor.const_get(:Logger)) end it "should create a logger with custom handler" do custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", nil) - logger = Featurevisor.create_logger(handler: custom_handler) + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler) logger.info("test message") end end @@ -38,7 +38,7 @@ describe "Logger" do describe "constructor" do it "should use default log level when none provided" do - logger = Featurevisor::Logger.new + logger = Featurevisor.const_get(:Logger).new logger.debug("debug message") # Debug should not be logged with default level (info) @@ -46,7 +46,7 @@ end it "should use provided log level" do - logger = Featurevisor::Logger.new(level: "debug") + logger = Featurevisor.const_get(:Logger).new(level: "debug") logger.debug("debug message") # Debug should be logged with debug level @@ -55,7 +55,7 @@ end it "should use default handler when none provided" do - logger = Featurevisor::Logger.new + logger = Featurevisor.const_get(:Logger).new logger.info("test message") expect(console_output.string).to include("[Featurevisor]") @@ -66,14 +66,14 @@ custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", nil) - logger = Featurevisor::Logger.new(handler: custom_handler) + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler) logger.info("test message") end end describe "set_level" do it "should update the log level" do - logger = Featurevisor::Logger.new(level: "info") + logger = Featurevisor.const_get(:Logger).new(level: "info") # Debug should not be logged initially logger.debug("debug message") @@ -92,14 +92,14 @@ levels.each do |level| console_output.truncate(0) - logger = Featurevisor::Logger.new(level: level) + logger = Featurevisor.const_get(:Logger).new(level: level) logger.error("error message") expect(console_output.string).to include("error message") end end it "should log warn messages at warn level and above" do - logger = Featurevisor::Logger.new(level: "warn") + logger = Featurevisor.const_get(:Logger).new(level: "warn") logger.warn("warn message") expect(console_output.string).to include("warn message") @@ -109,21 +109,21 @@ end it "should not log info messages at warn level" do - logger = Featurevisor::Logger.new(level: "warn") + logger = Featurevisor.const_get(:Logger).new(level: "warn") logger.info("info message") expect(console_output.string).not_to include("info message") end it "should not log debug messages at info level" do - logger = Featurevisor::Logger.new(level: "info") + logger = Featurevisor.const_get(:Logger).new(level: "info") logger.debug("debug message") expect(console_output.string).not_to include("debug message") end it "should log all messages at debug level" do - logger = Featurevisor::Logger.new(level: "debug") + logger = Featurevisor.const_get(:Logger).new(level: "debug") logger.debug("debug message") expect(console_output.string).to include("debug message") @@ -140,7 +140,7 @@ end describe "convenience methods" do - let(:logger) { Featurevisor::Logger.new(level: "debug") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "debug") } it "should call debug method correctly" do logger.debug("debug message") @@ -183,7 +183,7 @@ custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", { test: true }) - logger = Featurevisor::Logger.new(handler: custom_handler, level: "debug") + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler, level: "debug") details = { test: true } logger.log("info", "test message", details) @@ -193,7 +193,7 @@ custom_handler = double("custom_handler") expect(custom_handler).not_to receive(:call) - logger = Featurevisor::Logger.new(handler: custom_handler, level: "warn") + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler, level: "warn") logger.log("debug", "debug message") end end diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb new file mode 100644 index 0000000..990cbd1 --- /dev/null +++ b/spec/modules_spec.rb @@ -0,0 +1,314 @@ +require "featurevisor" + +RSpec.describe Featurevisor::Modules do + describe "Module" do + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } + + it "should be a class" do + expect(Featurevisor::Modules::FeaturevisorModule).to be_a(Class) + end + + it "should initialize with options" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + setup: ->(_api) {}, + before: ->(opts) { opts.merge(test: true) }, + after: ->(eval, opts) { eval.merge(test: true) }, + close: -> {} + ) + + expect(mod.name).to eq("test-mod") + expect(mod).to respond_to(:call_setup) + expect(mod).to respond_to(:call_before) + expect(mod).to respond_to(:call_after) + expect(mod).to respond_to(:call_close) + end + + it "should call setup when defined" do + received_api = nil + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + setup: ->(api) { received_api = api } + ) + + api = { get_revision: -> { "1" } } + mod.call_setup(api) + + expect(received_api).to eq(api) + end + + it "should call close when defined" do + closed = false + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + close: -> { closed = true } + ) + + mod.call_close + + expect(closed).to be true + end + + it "should call before mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + before: ->(opts) { opts.merge(test: true) } + ) + + result = mod.call_before({ original: true }) + expect(result[:test]).to be true + expect(result[:original]).to be true + end + + it "should return original options when before mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_before({ original: true }) + expect(result[:original]).to be true + expect(result[:test]).to be_nil + end + + it "should call bucket key mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } + ) + + result = mod.call_bucket_key({ bucket_key: "original" }) + expect(result).to eq("modified-original") + end + + it "should return original bucket key when bucket key mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_bucket_key({ bucket_key: "original" }) + expect(result).to eq("original") + end + + it "should call bucket value mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_value: ->(opts) { opts[:bucket_value] * 2 } + ) + + result = mod.call_bucket_value({ bucket_value: 50 }) + expect(result).to eq(100) + end + + it "should return original bucket value when bucket value mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_bucket_value({ bucket_value: 50 }) + expect(result).to eq(50) + end + + it "should call after mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + after: ->(eval, opts) { eval.merge(test: true) } + ) + + result = mod.call_after({ original: true }, { options: true }) + expect(result[:test]).to be true + expect(result[:original]).to be true + end + + it "should return original evaluation when after mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_after({ original: true }, { options: true }) + expect(result[:original]).to be true + expect(result[:test]).to be_nil + end + end + + describe "ModulesManager" do + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } + let(:diagnostics) { [] } + let(:modules_manager) do + Featurevisor::Modules::ModulesManager.new( + logger: logger, + report_diagnostic: ->(diagnostic, _mod = nil) { diagnostics << diagnostic } + ) + end + + it "should be a class" do + expect(Featurevisor::Modules::ModulesManager).to be_a(Class) + end + + it "should initialize with options" do + expect(modules_manager.modules).to eq([]) + end + + it "should add modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + remove_fn = modules_manager.add(mod) + + expect(modules_manager.modules).to include(mod) + expect(remove_fn).to be_a(Proc) + end + + it "should not add duplicate modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + modules_manager.add(module1) + result = modules_manager.add(module2) + + expect(modules_manager.modules).to eq([module1]) + expect(result).to be_nil + expect(diagnostics.last).to include( + level: "error", + code: "duplicate_module", + module_name: "test-mod" + ) + end + + it "should isolate setup failures and close the failed module" do + closed = false + subscriptions_cleared = false + manager = Featurevisor::Modules::ModulesManager.new( + report_diagnostic: ->(diagnostic, _mod = nil) { diagnostics << diagnostic }, + module_api_factory: ->(_mod) { {} }, + clear_module_diagnostic_subscriptions: ->(_mod) { subscriptions_cleared = true } + ) + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "broken-setup", + setup: ->(_api) { raise "setup failed" }, + close: -> { closed = true } + ) + + expect(manager.add(mod)).to be_nil + expect(manager.modules).to be_empty + expect(closed).to be true + expect(subscriptions_cleared).to be true + expect(diagnostics.last).to include(code: "module_setup_error", module_name: "broken-setup") + end + + it "should remove modules by name" do + closed = false + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod", close: -> { closed = true }) + modules_manager.add(mod) + + modules_manager.remove("test-mod") + expect(modules_manager.modules).to be_empty + expect(closed).to be true + end + + it "should get all modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "module1") + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "module2") + + modules_manager.add(module1) + modules_manager.add(module2) + + expect(modules_manager.get_all).to eq([module1, module2]) + end + + it "should run before modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module1", + before: ->(opts) { opts.merge(module1: true) } + ) + module2 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module2", + before: ->(opts) { opts.merge(module2: true) } + ) + + modules_manager.add(module1) + modules_manager.add(module2) + + result = modules_manager.run_before_modules({ original: true }) + expect(result[:module1]).to be true + expect(result[:module2]).to be true + expect(result[:original]).to be true + end + + it "should run bucket key modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } + ) + + modules_manager.add(mod) + + result = modules_manager.run_bucket_key_modules({ + feature_key: "test", + context: {}, + bucket_by: "userId", + bucket_key: "original" + }) + + expect(result).to eq("modified-original") + end + + it "should run bucket value modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_value: ->(opts) { opts[:bucket_value] * 2 } + ) + + modules_manager.add(mod) + + result = modules_manager.run_bucket_value_modules({ + feature_key: "test", + bucket_key: "test.123", + context: {}, + bucket_value: 50 + }) + + expect(result).to eq(100) + end + + it "should run after modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module1", + after: ->(eval, opts) { eval.merge(module1: true) } + ) + module2 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module2", + after: ->(eval, opts) { eval.merge(module2: true) } + ) + + modules_manager.add(module1) + modules_manager.add(module2) + + result = modules_manager.run_after_modules({ original: true }, { options: true }) + expect(result[:module1]).to be true + expect(result[:module2]).to be true + expect(result[:original]).to be true + end + + it "should initialize with existing modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + manager = Featurevisor::Modules::ModulesManager.new(modules: [mod], logger: logger) + + expect(manager.modules).to include(mod) + end + + it "should return remove function when adding mod" do + closed = [] + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod", close: -> { closed << "test-mod" }) + remove_fn = modules_manager.add(mod) + + expect(remove_fn).to be_a(Proc) + remove_fn.call + remove_fn.call + expect(modules_manager.modules).to be_empty + expect(closed).to eq(["test-mod"]) + end + + it "should close all modules" do + closed = [] + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "module1", close: -> { closed << "module1" }) + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "module2", close: -> { closed << "module2" }) + + modules_manager.add(module1) + modules_manager.add(module2) + modules_manager.close_all + + expect(closed).to eq(%w[module1 module2]) + expect(modules_manager.modules).to be_empty + end + end +end diff --git a/spec/test_command_spec.rb b/spec/test_command_spec.rb index 9fb374d..2f69af0 100644 --- a/spec/test_command_spec.rb +++ b/spec/test_command_spec.rb @@ -12,52 +12,37 @@ end describe "datafile routing helpers" do - it "prefers scoped datafile over tagged and base datafile" do + it "prefers target datafile over 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 } + "production-target-checkout" => { schemaVersion: "2", target: "checkout" } } assertion = { environment: "production", - scope: "browsers", - tag: "web" + target: "checkout" } 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" }) + expect(datafile[:target]).to eq("checkout") end end describe "build command generation" do - it "includes scope and environment flags when building scoped datafiles" do + it "includes target and environment flags when building target datafiles" do command = described_class.new(options) expect(command).to receive(:execute_command) - .with(include("featurevisor build", "--environment=production", "--scope=browsers", "--json")) + .with(include("featurevisor build", "--environment=production", "--target=checkout", "--json")) .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') datafile = command.send( :build_single_datafile, environment: "production", - schema_version: "2", inflate: nil, - scope: "browsers" + target: "checkout" ) expect(datafile[:schemaVersion]).to eq("2") @@ -73,7 +58,6 @@ datafile = command.send( :build_single_datafile, environment: false, - schema_version: nil, inflate: nil ) @@ -119,7 +103,7 @@ { description: "missing datafile assertion", environment: "production", - scope: "browsers" + target: "checkout" } ] } @@ -130,13 +114,13 @@ $stdout = output begin expect do - command.send(:run_tests, tests, {}, {}, "warn", { scopes: [] }) + command.send(:run_tests, tests, {}, {}, "warn", {}) 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("no datafile found for assertion target/environment combination") expect(output.string).to include("Test specs: 0 passed, 1 failed") expect(output.string).to include("Assertions: 0 passed, 1 failed") end