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
|