gitlab-experiment 0.6.4 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +410 -290
- data/lib/generators/gitlab/experiment/experiment_generator.rb +9 -4
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +87 -45
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +69 -3
- data/lib/gitlab/experiment/base_interface.rb +86 -24
- data/lib/gitlab/experiment/cache/redis_hash_store.rb +10 -10
- data/lib/gitlab/experiment/cache.rb +3 -7
- data/lib/gitlab/experiment/callbacks.rb +97 -5
- data/lib/gitlab/experiment/configuration.rb +209 -28
- data/lib/gitlab/experiment/context.rb +2 -3
- data/lib/gitlab/experiment/cookies.rb +0 -2
- data/lib/gitlab/experiment/engine.rb +2 -1
- data/lib/gitlab/experiment/errors.rb +21 -0
- data/lib/gitlab/experiment/nestable.rb +51 -0
- data/lib/gitlab/experiment/rollout/percent.rb +41 -16
- data/lib/gitlab/experiment/rollout/random.rb +25 -4
- data/lib/gitlab/experiment/rollout/round_robin.rb +27 -10
- data/lib/gitlab/experiment/rollout.rb +61 -12
- data/lib/gitlab/experiment/rspec.rb +224 -130
- data/lib/gitlab/experiment/test_behaviors/trackable.rb +69 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- data/lib/gitlab/experiment.rb +118 -56
- metadata +8 -24
| @@ -7,34 +7,83 @@ module Gitlab | |
| 7 7 | 
             
                  autoload :Random, 'gitlab/experiment/rollout/random.rb'
         | 
| 8 8 | 
             
                  autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
         | 
| 9 9 |  | 
| 10 | 
            -
                  def self.resolve(klass)
         | 
| 11 | 
            -
                     | 
| 12 | 
            -
             | 
| 13 | 
            -
             | 
| 10 | 
            +
                  def self.resolve(klass, options = {})
         | 
| 11 | 
            +
                    case klass
         | 
| 12 | 
            +
                    when String
         | 
| 13 | 
            +
                      Strategy.new(klass.classify.constantize, options)
         | 
| 14 | 
            +
                    when Symbol
         | 
| 15 | 
            +
                      Strategy.new("#{name}::#{klass.to_s.classify}".constantize, options)
         | 
| 16 | 
            +
                    when Class
         | 
| 17 | 
            +
                      Strategy.new(klass, options)
         | 
| 18 | 
            +
                    else
         | 
| 19 | 
            +
                      raise ArgumentError, "unable to resolve rollout from #{klass.inspect}"
         | 
| 20 | 
            +
                    end
         | 
| 14 21 | 
             
                  end
         | 
| 15 22 |  | 
| 16 23 | 
             
                  class Base
         | 
| 17 | 
            -
                     | 
| 24 | 
            +
                    DEFAULT_OPTIONS = {
         | 
| 25 | 
            +
                      include_control: false
         | 
| 26 | 
            +
                    }.freeze
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    attr_reader :experiment, :options
         | 
| 18 29 |  | 
| 19 30 | 
             
                    delegate :variant_names, :cache, :id, to: :experiment
         | 
| 20 31 |  | 
| 21 32 | 
             
                    def initialize(options = {})
         | 
| 22 | 
            -
                      @options = options
         | 
| 23 | 
            -
                      # validate! # we want to validate here, but we can't yet
         | 
| 33 | 
            +
                      @options = DEFAULT_OPTIONS.merge(options)
         | 
| 24 34 | 
             
                    end
         | 
| 25 35 |  | 
| 26 | 
            -
                    def  | 
| 36 | 
            +
                    def for(experiment)
         | 
| 37 | 
            +
                      raise ArgumentError, 'you must provide an experiment instance' unless experiment.class <= Gitlab::Experiment
         | 
| 38 | 
            +
             | 
| 27 39 | 
             
                      @experiment = experiment
         | 
| 28 | 
            -
             | 
| 29 | 
            -
                       | 
| 40 | 
            +
             | 
| 41 | 
            +
                      self
         | 
| 42 | 
            +
                    end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                    def enabled?
         | 
| 45 | 
            +
                      require_experiment(__method__)
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                      true
         | 
| 30 48 | 
             
                    end
         | 
| 31 49 |  | 
| 50 | 
            +
                    def resolve
         | 
| 51 | 
            +
                      require_experiment(__method__)
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                      return nil if @experiment.respond_to?(:experiment_group?) && !@experiment.experiment_group?
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                      validate! # allow the rollout strategy to validate itself
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                      assignment = execute_assignment
         | 
| 58 | 
            +
                      assignment == :control ? nil : assignment # avoid caching control
         | 
| 59 | 
            +
                    end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                    protected
         | 
| 62 | 
            +
             | 
| 32 63 | 
             
                    def validate!
         | 
| 33 64 | 
             
                      # base is always valid
         | 
| 34 65 | 
             
                    end
         | 
| 35 66 |  | 
| 36 | 
            -
                    def  | 
| 37 | 
            -
                       | 
| 67 | 
            +
                    def execute_assignment
         | 
| 68 | 
            +
                      behavior_names.first
         | 
| 69 | 
            +
                    end
         | 
| 70 | 
            +
             | 
| 71 | 
            +
                    private
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                    def require_experiment(method_name)
         | 
| 74 | 
            +
                      return if @experiment.present?
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                      raise ArgumentError, "you need to call `for` with an experiment instance before chaining `#{method_name}`"
         | 
| 77 | 
            +
                    end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                    def behavior_names
         | 
| 80 | 
            +
                      options[:include_control] ? [:control] + variant_names : variant_names
         | 
| 81 | 
            +
                    end
         | 
| 82 | 
            +
                  end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                  Strategy = Struct.new(:klass, :options) do
         | 
| 85 | 
            +
                    def for(experiment)
         | 
| 86 | 
            +
                      klass.new(options).for(experiment)
         | 
| 38 87 | 
             
                    end
         | 
| 39 88 | 
             
                  end
         | 
| 40 89 | 
             
                end
         | 
| @@ -2,226 +2,297 @@ | |
| 2 2 |  | 
| 3 3 | 
             
            module Gitlab
         | 
| 4 4 | 
             
              class Experiment
         | 
| 5 | 
            -
                module  | 
| 6 | 
            -
                   | 
| 7 | 
            -
             | 
| 8 | 
            -
                  end
         | 
| 9 | 
            -
             | 
| 10 | 
            -
                  def wrapped_experiment(experiment, times = nil, expected = false, &block)
         | 
| 11 | 
            -
                    klass, experiment_name, variant_name = *experiment_details(experiment)
         | 
| 12 | 
            -
                    base_klass = Configuration.base_class.constantize
         | 
| 5 | 
            +
                module TestBehaviors
         | 
| 6 | 
            +
                  autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
         | 
| 7 | 
            +
                end
         | 
| 13 8 |  | 
| 14 | 
            -
             | 
| 15 | 
            -
                    experiment_klasses = base_klass.descendants.reject { |k| k == klass }
         | 
| 16 | 
            -
                    experiment_klasses.push(base_klass).each do |k|
         | 
| 17 | 
            -
                      allow(k).to receive(:new).and_call_original
         | 
| 18 | 
            -
                    end
         | 
| 9 | 
            +
                WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks)
         | 
| 19 10 |  | 
| 20 | 
            -
             | 
| 11 | 
            +
                module RSpecMocks
         | 
| 12 | 
            +
                  @__gitlab_experiment_receivers = {}
         | 
| 21 13 |  | 
| 22 | 
            -
             | 
| 23 | 
            -
                     | 
| 14 | 
            +
                  def self.track_gitlab_experiment_receiver(method, receiver)
         | 
| 15 | 
            +
                    # Leverage the `>=` method on Gitlab::Experiment to determine if the receiver is an experiment, not the other
         | 
| 16 | 
            +
                    # way round -- `receiver.<=` could be mocked and we want to be extra careful.
         | 
| 17 | 
            +
                    (@__gitlab_experiment_receivers[method.to_s] ||= []) << receiver if Gitlab::Experiment >= receiver
         | 
| 18 | 
            +
                  rescue StandardError # again, let's just be extra careful
         | 
| 19 | 
            +
                    false
         | 
| 20 | 
            +
                  end
         | 
| 24 21 |  | 
| 25 | 
            -
             | 
| 22 | 
            +
                  def self.bind_gitlab_experiment_receiver(method)
         | 
| 23 | 
            +
                    method.unbind.bind(@__gitlab_experiment_receivers[method.to_s].pop)
         | 
| 24 | 
            +
                  end
         | 
| 26 25 |  | 
| 27 | 
            -
             | 
| 28 | 
            -
                     | 
| 29 | 
            -
             | 
| 30 | 
            -
                       | 
| 31 | 
            -
             | 
| 32 | 
            -
             | 
| 26 | 
            +
                  module MethodDouble
         | 
| 27 | 
            +
                    def proxy_method_invoked(receiver, *args, &block)
         | 
| 28 | 
            +
                      RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
         | 
| 29 | 
            +
                      super
         | 
| 30 | 
            +
                    end
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
                end
         | 
| 33 33 |  | 
| 34 | 
            -
             | 
| 35 | 
            -
             | 
| 36 | 
            -
             | 
| 37 | 
            -
             | 
| 34 | 
            +
                module RSpecHelpers
         | 
| 35 | 
            +
                  def stub_experiments(experiments)
         | 
| 36 | 
            +
                    experiments.each do |experiment|
         | 
| 37 | 
            +
                      wrapped_experiment(experiment, remock: true) do |instance, wrapped|
         | 
| 38 | 
            +
                        # Stub internal methods that will make it behave as we've instructed.
         | 
| 39 | 
            +
                        allow(instance).to receive(:enabled?) { wrapped.variant_name != false }
         | 
| 40 | 
            +
                        if instance.respond_to?(:experiment_group?, true)
         | 
| 41 | 
            +
                          allow(instance).to receive(:experiment_group?) { !(wrapped.variant_name == false) }
         | 
| 38 42 | 
             
                        end
         | 
| 39 43 |  | 
| 40 | 
            -
                        # Stub | 
| 41 | 
            -
                         | 
| 42 | 
            -
             | 
| 43 | 
            -
             | 
| 44 | 
            +
                        # Stub the variant resolution logic to handle true/false, and named variants.
         | 
| 45 | 
            +
                        allow(instance).to receive(:resolve_variant_name).and_wrap_original { |method|
         | 
| 46 | 
            +
                          # Call the original method if we specified simply `true`.
         | 
| 47 | 
            +
                          wrapped.variant_name == true ? method.call : wrapped.variant_name
         | 
| 48 | 
            +
                        }
         | 
| 44 49 | 
             
                      end
         | 
| 45 50 | 
             
                    end
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                    wrapped_experiments
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  def wrapped_experiment(experiment, remock: false, &block)
         | 
| 56 | 
            +
                    klass, experiment_name, variant_name = *extract_experiment_details(experiment)
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                    wrapped_experiment = wrapped_experiments[experiment_name] =
         | 
| 59 | 
            +
                      (!remock && wrapped_experiments[experiment_name]) ||
         | 
| 60 | 
            +
                      WrappedExperiment.new(klass, experiment_name, variant_name, wrapped_experiment_chain_for(klass), [])
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                    wrapped_experiment.blocks << block if block
         | 
| 63 | 
            +
                    wrapped_experiment
         | 
| 46 64 | 
             
                  end
         | 
| 47 65 |  | 
| 48 66 | 
             
                  private
         | 
| 49 67 |  | 
| 50 | 
            -
                  def  | 
| 51 | 
            -
                     | 
| 52 | 
            -
             | 
| 53 | 
            -
             | 
| 68 | 
            +
                  def wrapped_experiments
         | 
| 69 | 
            +
                    @__wrapped_experiments ||= defined?(HashWithIndifferentAccess) ? HashWithIndifferentAccess.new : {}
         | 
| 70 | 
            +
                  end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                  def wrapped_experiment_chain_for(klass)
         | 
| 73 | 
            +
                    @__wrapped_experiment_chains ||= {}
         | 
| 74 | 
            +
                    @__wrapped_experiment_chains[klass.name || klass.object_id] ||= begin
         | 
| 75 | 
            +
                      allow(klass).to receive(:new).and_wrap_original do |method, *args, &original_block|
         | 
| 76 | 
            +
                        RSpecMocks.bind_gitlab_experiment_receiver(method).call(*args).tap do |instance|
         | 
| 77 | 
            +
                          wrapped = @__wrapped_experiments[instance.instance_variable_get(:@_name)]
         | 
| 78 | 
            +
                          wrapped&.blocks&.each { |b| b.call(instance, wrapped) }
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                          original_block&.call(instance)
         | 
| 81 | 
            +
                        end
         | 
| 82 | 
            +
                      end
         | 
| 54 83 | 
             
                    end
         | 
| 84 | 
            +
                  end
         | 
| 55 85 |  | 
| 86 | 
            +
                  def extract_experiment_details(experiment)
         | 
| 87 | 
            +
                    experiment_name = nil
         | 
| 88 | 
            +
                    variant_name = nil
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                    experiment_name = experiment if experiment.is_a?(Symbol)
         | 
| 56 91 | 
             
                    experiment_name, variant_name = *experiment if experiment.is_a?(Array)
         | 
| 57 92 |  | 
| 58 93 | 
             
                    base_klass = Configuration.base_class.constantize
         | 
| 59 | 
            -
                    variant_name = experiment. | 
| 94 | 
            +
                    variant_name = experiment.assigned.name if experiment.is_a?(base_klass)
         | 
| 60 95 |  | 
| 61 | 
            -
                     | 
| 62 | 
            -
             | 
| 63 | 
            -
                    elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
         | 
| 64 | 
            -
                      klass = experiment
         | 
| 65 | 
            -
                    else
         | 
| 66 | 
            -
                      experiment_name ||= experiment.instance_variable_get(:@name)
         | 
| 67 | 
            -
                      klass = base_klass.constantize(experiment_name)
         | 
| 68 | 
            -
                    end
         | 
| 96 | 
            +
                    resolved_klass = experiment_klass(experiment) { base_klass.constantize(experiment_name) }
         | 
| 97 | 
            +
                    experiment_name ||= experiment.instance_variable_get(:@_name)
         | 
| 69 98 |  | 
| 70 | 
            -
                     | 
| 71 | 
            -
             | 
| 99 | 
            +
                    [resolved_klass, experiment_name.to_s, variant_name]
         | 
| 100 | 
            +
                  end
         | 
| 72 101 |  | 
| 73 | 
            -
             | 
| 74 | 
            -
             | 
| 102 | 
            +
                  def experiment_klass(experiment, &block)
         | 
| 103 | 
            +
                    if experiment.class.name.nil? # anonymous class instance
         | 
| 104 | 
            +
                      experiment.class
         | 
| 105 | 
            +
                    elsif experiment.instance_of?(Class) # class level stubbing, eg. "MyExperiment"
         | 
| 106 | 
            +
                      experiment
         | 
| 107 | 
            +
                    elsif block
         | 
| 108 | 
            +
                      yield
         | 
| 75 109 | 
             
                    end
         | 
| 76 | 
            -
             | 
| 77 | 
            -
                    [klass, experiment_name, variant_name]
         | 
| 78 110 | 
             
                  end
         | 
| 79 111 | 
             
                end
         | 
| 80 112 |  | 
| 81 113 | 
             
                module RSpecMatchers
         | 
| 82 114 | 
             
                  extend RSpec::Matchers::DSL
         | 
| 83 115 |  | 
| 84 | 
            -
                  def require_experiment(experiment,  | 
| 116 | 
            +
                  def require_experiment(experiment, matcher, instances_only: true)
         | 
| 85 117 | 
             
                    klass = experiment.instance_of?(Class) ? experiment : experiment.class
         | 
| 86 | 
            -
                    unless klass <= Gitlab::Experiment
         | 
| 87 | 
            -
             | 
| 88 | 
            -
             | 
| 89 | 
            -
             | 
| 90 | 
            -
             | 
| 118 | 
            +
                    raise ArgumentError, "the #{matcher} matcher is limited to experiments" unless klass <= Gitlab::Experiment
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                    if instances_only && experiment == klass
         | 
| 121 | 
            +
                      raise ArgumentError, "the #{matcher} matcher is limited to experiment instances"
         | 
| 122 | 
            +
                    end
         | 
| 123 | 
            +
             | 
| 124 | 
            +
                    experiment
         | 
| 125 | 
            +
                  end
         | 
| 126 | 
            +
             | 
| 127 | 
            +
                  matcher :register_behavior do |behavior_name|
         | 
| 128 | 
            +
                    match do |experiment|
         | 
| 129 | 
            +
                      @experiment = require_experiment(experiment, 'register_behavior')
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                      block = @experiment.behaviors[behavior_name.to_s]
         | 
| 132 | 
            +
                      @return_expected = false unless block
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                      if @return_expected
         | 
| 135 | 
            +
                        @actual_return = block.call
         | 
| 136 | 
            +
                        @expected_return == @actual_return
         | 
| 137 | 
            +
                      else
         | 
| 138 | 
            +
                        block
         | 
| 139 | 
            +
                      end
         | 
| 140 | 
            +
                    end
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                    chain :with do |expected|
         | 
| 143 | 
            +
                      @return_expected = true
         | 
| 144 | 
            +
                      @expected_return = expected
         | 
| 91 145 | 
             
                    end
         | 
| 92 146 |  | 
| 93 | 
            -
                     | 
| 94 | 
            -
                       | 
| 147 | 
            +
                    failure_message do
         | 
| 148 | 
            +
                      add_details("expected the #{behavior_name} behavior to be registered")
         | 
| 149 | 
            +
                    end
         | 
| 150 | 
            +
             | 
| 151 | 
            +
                    failure_message_when_negated do
         | 
| 152 | 
            +
                      add_details("expected the #{behavior_name} behavior not to be registered")
         | 
| 95 153 | 
             
                    end
         | 
| 96 154 |  | 
| 97 | 
            -
                     | 
| 155 | 
            +
                    def add_details(base)
         | 
| 156 | 
            +
                      details = []
         | 
| 157 | 
            +
             | 
| 158 | 
            +
                      if @return_expected
         | 
| 159 | 
            +
                        base = "#{base} with a return value"
         | 
| 160 | 
            +
                        details << "    expected return: #{@expected_return.inspect}\n" \
         | 
| 161 | 
            +
                                   "      actual return: #{@actual_return.inspect}"
         | 
| 162 | 
            +
                      else
         | 
| 163 | 
            +
                        details << "    behaviors: #{@experiment.behaviors.keys.inspect}"
         | 
| 164 | 
            +
                      end
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                      details.unshift(base).join("\n")
         | 
| 167 | 
            +
                    end
         | 
| 98 168 | 
             
                  end
         | 
| 99 169 |  | 
| 100 170 | 
             
                  matcher :exclude do |context|
         | 
| 101 | 
            -
                    ivar = :'@excluded'
         | 
| 102 | 
            -
             | 
| 103 171 | 
             
                    match do |experiment|
         | 
| 104 | 
            -
                      require_experiment(experiment, 'exclude')
         | 
| 105 | 
            -
                      experiment.context(context)
         | 
| 172 | 
            +
                      @experiment = require_experiment(experiment, 'exclude')
         | 
| 173 | 
            +
                      @experiment.context(context)
         | 
| 174 | 
            +
                      @experiment.instance_variable_set(:@_excluded, nil)
         | 
| 106 175 |  | 
| 107 | 
            -
                      experiment. | 
| 108 | 
            -
                      !experiment.run_callbacks(:exclusion_check) { :not_excluded }
         | 
| 176 | 
            +
                      !@experiment.run_callbacks(:exclusion_check) { :not_excluded }
         | 
| 109 177 | 
             
                    end
         | 
| 110 178 |  | 
| 111 179 | 
             
                    failure_message do
         | 
| 112 | 
            -
                       | 
| 180 | 
            +
                      "expected #{context} to be excluded"
         | 
| 113 181 | 
             
                    end
         | 
| 114 182 |  | 
| 115 183 | 
             
                    failure_message_when_negated do
         | 
| 116 | 
            -
                       | 
| 184 | 
            +
                      "expected #{context} not to be excluded"
         | 
| 117 185 | 
             
                    end
         | 
| 118 186 | 
             
                  end
         | 
| 119 187 |  | 
| 120 188 | 
             
                  matcher :segment do |context|
         | 
| 121 | 
            -
                    ivar = :'@variant_name'
         | 
| 122 | 
            -
             | 
| 123 189 | 
             
                    match do |experiment|
         | 
| 124 | 
            -
                      require_experiment(experiment, 'segment')
         | 
| 125 | 
            -
                      experiment.context(context)
         | 
| 126 | 
            -
             | 
| 127 | 
            -
                      experiment. | 
| 128 | 
            -
                      experiment.run_callbacks(:segmentation_check)
         | 
| 190 | 
            +
                      @experiment = require_experiment(experiment, 'segment')
         | 
| 191 | 
            +
                      @experiment.context(context)
         | 
| 192 | 
            +
                      @experiment.instance_variable_set(:@_assigned_variant_name, nil)
         | 
| 193 | 
            +
                      @experiment.run_callbacks(:segmentation)
         | 
| 129 194 |  | 
| 130 | 
            -
                      @ | 
| 131 | 
            -
                      @ | 
| 195 | 
            +
                      @actual_variant = @experiment.instance_variable_get(:@_assigned_variant_name)
         | 
| 196 | 
            +
                      @expected_variant ? @actual_variant.to_s == @expected_variant.to_s : @actual_variant.present?
         | 
| 132 197 | 
             
                    end
         | 
| 133 198 |  | 
| 134 199 | 
             
                    chain :into do |expected|
         | 
| 135 200 | 
             
                      raise ArgumentError, 'variant name must be provided' if expected.blank?
         | 
| 136 201 |  | 
| 137 | 
            -
                      @ | 
| 202 | 
            +
                      @expected_variant = expected.to_s
         | 
| 138 203 | 
             
                    end
         | 
| 139 204 |  | 
| 140 205 | 
             
                    failure_message do
         | 
| 141 | 
            -
                       | 
| 206 | 
            +
                      add_details("expected #{context} to be segmented")
         | 
| 142 207 | 
             
                    end
         | 
| 143 208 |  | 
| 144 209 | 
             
                    failure_message_when_negated do
         | 
| 145 | 
            -
                       | 
| 210 | 
            +
                      add_details("expected #{context} not to be segmented")
         | 
| 146 211 | 
             
                    end
         | 
| 147 212 |  | 
| 148 | 
            -
                    def  | 
| 149 | 
            -
                       | 
| 150 | 
            -
             | 
| 151 | 
            -
                       | 
| 152 | 
            -
             | 
| 213 | 
            +
                    def add_details(base)
         | 
| 214 | 
            +
                      details = []
         | 
| 215 | 
            +
             | 
| 216 | 
            +
                      if @expected_variant
         | 
| 217 | 
            +
                        base = "#{base} into variant"
         | 
| 218 | 
            +
                        details << "    expected variant: #{@expected_variant.inspect}\n" \
         | 
| 219 | 
            +
                                   "      actual variant: #{@actual_variant.inspect}"
         | 
| 220 | 
            +
                      end
         | 
| 221 | 
            +
             | 
| 222 | 
            +
                      details.unshift(base).join("\n")
         | 
| 153 223 | 
             
                    end
         | 
| 154 224 | 
             
                  end
         | 
| 155 225 |  | 
| 156 226 | 
             
                  matcher :track do |event, *event_args|
         | 
| 157 227 | 
             
                    match do |experiment|
         | 
| 158 | 
            -
                       | 
| 228 | 
            +
                      @experiment = require_experiment(experiment, 'track', instances_only: false)
         | 
| 229 | 
            +
             | 
| 230 | 
            +
                      set_expectations(event, *event_args, negated: false)
         | 
| 159 231 | 
             
                    end
         | 
| 160 232 |  | 
| 161 233 | 
             
                    match_when_negated do |experiment|
         | 
| 162 | 
            -
                       | 
| 234 | 
            +
                      @experiment = require_experiment(experiment, 'track', instances_only: false)
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                      set_expectations(event, *event_args, negated: true)
         | 
| 163 237 | 
             
                    end
         | 
| 164 238 |  | 
| 165 | 
            -
                    chain | 
| 239 | 
            +
                    chain(:for) do |expected|
         | 
| 166 240 | 
             
                      raise ArgumentError, 'variant name must be provided' if expected.blank?
         | 
| 167 241 |  | 
| 168 | 
            -
                      @expected_variant =  | 
| 242 | 
            +
                      @expected_variant = expected.to_s
         | 
| 169 243 | 
             
                    end
         | 
| 170 244 |  | 
| 171 | 
            -
                    chain(:with_context)  | 
| 245 | 
            +
                    chain(:with_context) do |expected|
         | 
| 246 | 
            +
                      raise ArgumentError, 'context name must be provided' if expected.nil?
         | 
| 172 247 |  | 
| 173 | 
            -
             | 
| 248 | 
            +
                      @expected_context = expected
         | 
| 249 | 
            +
                    end
         | 
| 174 250 |  | 
| 175 | 
            -
                     | 
| 176 | 
            -
                      klass = experiment.instance_of?(Class) ? experiment : experiment.class
         | 
| 177 | 
            -
                      unless klass <= Gitlab::Experiment
         | 
| 178 | 
            -
                        raise(
         | 
| 179 | 
            -
                          ArgumentError,
         | 
| 180 | 
            -
                          "track matcher is limited to experiment instances and classes"
         | 
| 181 | 
            -
                        )
         | 
| 182 | 
            -
                      end
         | 
| 251 | 
            +
                    chain(:on_next_instance) { @on_next_instance = true }
         | 
| 183 252 |  | 
| 253 | 
            +
                    def set_expectations(event, *event_args, negated:)
         | 
| 254 | 
            +
                      failure_message = failure_message_with_details(event, negated: negated)
         | 
| 184 255 | 
             
                      expectations = proc do |e|
         | 
| 185 | 
            -
                        @experiment = e
         | 
| 186 256 | 
             
                        allow(e).to receive(:track).and_call_original
         | 
| 187 257 |  | 
| 188 258 | 
             
                        if negated
         | 
| 189 | 
            -
                           | 
| 190 | 
            -
             | 
| 191 | 
            -
                          if @expected_variant
         | 
| 192 | 
            -
                            expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
         | 
| 193 | 
            -
                          end
         | 
| 194 | 
            -
             | 
| 195 | 
            -
                          if @expected_context
         | 
| 196 | 
            -
                            expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
         | 
| 259 | 
            +
                          if @expected_variant || @expected_context
         | 
| 260 | 
            +
                            raise ArgumentError, 'cannot specify `for` or `with_context` when negating on tracking calls'
         | 
| 197 261 | 
             
                          end
         | 
| 198 262 |  | 
| 199 | 
            -
                          expect(e). | 
| 263 | 
            +
                          expect(e).not_to receive(:track).with(*[event, *event_args]), failure_message
         | 
| 264 | 
            +
                        else
         | 
| 265 | 
            +
                          expect(e.assigned.name).to(eq(@expected_variant), failure_message) if @expected_variant
         | 
| 266 | 
            +
                          expect(e.context.value).to(include(@expected_context), failure_message) if @expected_context
         | 
| 267 | 
            +
                          expect(e).to receive(:track).with(*[event, *event_args]).and_call_original, failure_message
         | 
| 200 268 | 
             
                        end
         | 
| 201 269 | 
             
                      end
         | 
| 202 270 |  | 
| 203 | 
            -
                      if experiment.instance_of?(Class) | 
| 204 | 
            -
             | 
| 205 | 
            -
                       | 
| 206 | 
            -
             | 
| 207 | 
            -
             | 
| 271 | 
            +
                      return wrapped_experiment(@experiment, &expectations) if @on_next_instance || @experiment.instance_of?(Class)
         | 
| 272 | 
            +
             | 
| 273 | 
            +
                      expectations.call(@experiment)
         | 
| 274 | 
            +
                    end
         | 
| 275 | 
            +
             | 
| 276 | 
            +
                    def failure_message_with_details(event, negated: false)
         | 
| 277 | 
            +
                      add_details("expected #{@experiment.inspect} #{negated ? 'not to' : 'to'} have tracked #{event.inspect}")
         | 
| 208 278 | 
             
                    end
         | 
| 209 279 |  | 
| 210 | 
            -
                    def  | 
| 211 | 
            -
                       | 
| 212 | 
            -
             | 
| 213 | 
            -
             | 
| 214 | 
            -
             | 
| 215 | 
            -
             | 
| 216 | 
            -
             | 
| 217 | 
            -
                        MESSAGE
         | 
| 218 | 
            -
                      when :context
         | 
| 219 | 
            -
                        <<~MESSAGE.strip
         | 
| 220 | 
            -
                          expected #{@experiment.inspect} to have tracked #{event.inspect} with context
         | 
| 221 | 
            -
                              expected context: #{@expected_context}
         | 
| 222 | 
            -
                                actual context: #{@experiment.context.value}
         | 
| 223 | 
            -
                        MESSAGE
         | 
| 280 | 
            +
                    def add_details(base)
         | 
| 281 | 
            +
                      details = []
         | 
| 282 | 
            +
             | 
| 283 | 
            +
                      if @expected_variant
         | 
| 284 | 
            +
                        base = "#{base} for variant"
         | 
| 285 | 
            +
                        details << "    expected variant: #{@expected_variant.inspect}\n" \
         | 
| 286 | 
            +
                                   "      actual variant: #{@experiment.assigned.name.inspect})"
         | 
| 224 287 | 
             
                      end
         | 
| 288 | 
            +
             | 
| 289 | 
            +
                      if @expected_context
         | 
| 290 | 
            +
                        base = "#{base} with context"
         | 
| 291 | 
            +
                        details << "    expected context: #{@expected_context.inspect}\n" \
         | 
| 292 | 
            +
                                   "      actual context: #{@experiment.context.value.inspect})"
         | 
| 293 | 
            +
                      end
         | 
| 294 | 
            +
             | 
| 295 | 
            +
                      details.unshift(base).join("\n")
         | 
| 225 296 | 
             
                    end
         | 
| 226 297 | 
             
                  end
         | 
| 227 298 | 
             
                end
         | 
| @@ -232,12 +303,35 @@ RSpec.configure do |config| | |
| 232 303 | 
             
              config.include Gitlab::Experiment::RSpecHelpers
         | 
| 233 304 | 
             
              config.include Gitlab::Experiment::Dsl
         | 
| 234 305 |  | 
| 235 | 
            -
               | 
| 306 | 
            +
              clear_cache = proc do
         | 
| 236 307 | 
             
                RequestStore.clear!
         | 
| 308 | 
            +
             | 
| 309 | 
            +
                if defined?(Gitlab::Experiment::TestBehaviors::TrackedStructure)
         | 
| 310 | 
            +
                  Gitlab::Experiment::TestBehaviors::TrackedStructure.reset!
         | 
| 311 | 
            +
                end
         | 
| 237 312 | 
             
              end
         | 
| 238 313 |  | 
| 314 | 
            +
              config.before(:each, :experiment, &clear_cache)
         | 
| 315 | 
            +
              config.before(:each, type: :experiment, &clear_cache)
         | 
| 316 | 
            +
             | 
| 239 317 | 
             
              config.include Gitlab::Experiment::RSpecMatchers, :experiment
         | 
| 240 | 
            -
              config. | 
| 241 | 
            -
             | 
| 318 | 
            +
              config.include Gitlab::Experiment::RSpecMatchers, type: :experiment
         | 
| 319 | 
            +
             | 
| 320 | 
            +
              config.define_derived_metadata(file_path: Regexp.new('spec/experiments/')) do |metadata|
         | 
| 321 | 
            +
                metadata[:type] ||= :experiment
         | 
| 242 322 | 
             
              end
         | 
| 323 | 
            +
             | 
| 324 | 
            +
              # We need to monkeypatch rspec-mocks because there's an issue around stubbing class methods that impacts us here.
         | 
| 325 | 
            +
              #
         | 
| 326 | 
            +
              # You can find out what the outcome is of the issues I've opened on rspec-mocks, and maybe some day this won't be
         | 
| 327 | 
            +
              # needed.
         | 
| 328 | 
            +
              #
         | 
| 329 | 
            +
              # https://github.com/rspec/rspec-mocks/issues/1452
         | 
| 330 | 
            +
              # https://github.com/rspec/rspec-mocks/issues/1451 (closed)
         | 
| 331 | 
            +
              #
         | 
| 332 | 
            +
              # The other way I've considered patching this is inside gitlab-experiment itself, by adding an Anonymous class and
         | 
| 333 | 
            +
              # instantiating that instead of the configured base_class, and then it's less common but still possible to run into
         | 
| 334 | 
            +
              # the issue.
         | 
| 335 | 
            +
              require 'rspec/mocks/method_double'
         | 
| 336 | 
            +
              RSpec::Mocks::MethodDouble.prepend(Gitlab::Experiment::RSpecMocks::MethodDouble)
         | 
| 243 337 | 
             
            end
         | 
| @@ -0,0 +1,69 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module TestBehaviors
         | 
| 6 | 
            +
                  module Trackable
         | 
| 7 | 
            +
                    private
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                    def manage_nested_stack
         | 
| 10 | 
            +
                      TrackedStructure.push(self)
         | 
| 11 | 
            +
                      super
         | 
| 12 | 
            +
                    ensure
         | 
| 13 | 
            +
                      TrackedStructure.pop
         | 
| 14 | 
            +
                    end
         | 
| 15 | 
            +
                  end
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  class TrackedStructure
         | 
| 18 | 
            +
                    include Singleton
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                    # dependency tracking
         | 
| 21 | 
            +
                    @flat = {}
         | 
| 22 | 
            +
                    @stack = []
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    # structure tracking
         | 
| 25 | 
            +
                    @tree = { name: nil, count: 0, children: {} }
         | 
| 26 | 
            +
                    @node = @tree
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    class << self
         | 
| 29 | 
            +
                      def reset!
         | 
| 30 | 
            +
                        # dependency tracking
         | 
| 31 | 
            +
                        @flat = {}
         | 
| 32 | 
            +
                        @stack = []
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                        # structure tracking
         | 
| 35 | 
            +
                        @tree = { name: nil, count: 0, children: {} }
         | 
| 36 | 
            +
                        @node = @tree
         | 
| 37 | 
            +
                      end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                      def hierarchy
         | 
| 40 | 
            +
                        @tree[:children]
         | 
| 41 | 
            +
                      end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                      def dependencies
         | 
| 44 | 
            +
                        @flat
         | 
| 45 | 
            +
                      end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                      def push(instance)
         | 
| 48 | 
            +
                        # dependency tracking
         | 
| 49 | 
            +
                        @flat[instance.name] = ((@flat[instance.name] || []) + @stack.map(&:name)).uniq
         | 
| 50 | 
            +
                        @stack.push(instance)
         | 
| 51 | 
            +
             | 
| 52 | 
            +
                        # structure tracking
         | 
| 53 | 
            +
                        @last = @node
         | 
| 54 | 
            +
                        @node = @node[:children][instance.name] ||= { name: instance.name, count: 0, children: {} }
         | 
| 55 | 
            +
                        @node[:count] += 1
         | 
| 56 | 
            +
                      end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
                      def pop
         | 
| 59 | 
            +
                        # dependency tracking
         | 
| 60 | 
            +
                        @stack.pop
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                        # structure tracking
         | 
| 63 | 
            +
                        @node = @last
         | 
| 64 | 
            +
                      end
         | 
| 65 | 
            +
                    end
         | 
| 66 | 
            +
                  end
         | 
| 67 | 
            +
                end
         | 
| 68 | 
            +
              end
         | 
| 69 | 
            +
            end
         |