activeexperiment 0.1.0.alpha
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +0 -0
- data/MIT-LICENSE +20 -0
- data/README.md +373 -0
- data/lib/active_experiment/base.rb +76 -0
- data/lib/active_experiment/cache/active_record_cache_store.rb +116 -0
- data/lib/active_experiment/cache/redis_hash_cache_store.rb +68 -0
- data/lib/active_experiment/cache.rb +28 -0
- data/lib/active_experiment/caching.rb +172 -0
- data/lib/active_experiment/callbacks.rb +111 -0
- data/lib/active_experiment/capturable.rb +117 -0
- data/lib/active_experiment/configured_experiment.rb +74 -0
- data/lib/active_experiment/core.rb +177 -0
- data/lib/active_experiment/executed.rb +86 -0
- data/lib/active_experiment/execution.rb +156 -0
- data/lib/active_experiment/gem_version.rb +18 -0
- data/lib/active_experiment/instrumentation.rb +45 -0
- data/lib/active_experiment/log_subscriber.rb +178 -0
- data/lib/active_experiment/logging.rb +62 -0
- data/lib/active_experiment/railtie.rb +69 -0
- data/lib/active_experiment/rollout.rb +106 -0
- data/lib/active_experiment/rollouts/inactive_rollout.rb +24 -0
- data/lib/active_experiment/rollouts/percent_rollout.rb +84 -0
- data/lib/active_experiment/rollouts/random_rollout.rb +46 -0
- data/lib/active_experiment/rollouts.rb +127 -0
- data/lib/active_experiment/rspec.rb +12 -0
- data/lib/active_experiment/run_key.rb +55 -0
- data/lib/active_experiment/segments.rb +69 -0
- data/lib/active_experiment/test_case.rb +11 -0
- data/lib/active_experiment/test_helper.rb +267 -0
- data/lib/active_experiment/variants.rb +145 -0
- data/lib/active_experiment/version.rb +11 -0
- data/lib/active_experiment.rb +27 -0
- data/lib/activeexperiment.rb +8 -0
- data/lib/rails/generators/experiment/USAGE +12 -0
- data/lib/rails/generators/experiment/experiment_generator.rb +53 -0
- data/lib/rails/generators/experiment/templates/application_experiment.rb.tt +4 -0
- data/lib/rails/generators/experiment/templates/experiment.rb.tt +35 -0
- data/lib/rails/generators/rspec/experiment/experiment_generator.rb +20 -0
- data/lib/rails/generators/rspec/experiment/templates/experiment_spec.rb.tt +7 -0
- data/lib/rails/generators/test_unit/experiment/experiment_generator.rb +21 -0
- data/lib/rails/generators/test_unit/experiment/templates/experiment_test.rb.tt +9 -0
- metadata +118 -0
@@ -0,0 +1,267 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/class/subclasses"
|
4
|
+
require "active_support/testing/assertions"
|
5
|
+
|
6
|
+
module ActiveExperiment
|
7
|
+
# Provides helper methods for testing Active Experiments.
|
8
|
+
module TestHelper
|
9
|
+
include ActiveSupport::Testing::Assertions
|
10
|
+
|
11
|
+
def setup
|
12
|
+
super
|
13
|
+
clear_executed_experiments
|
14
|
+
end
|
15
|
+
|
16
|
+
def teardown
|
17
|
+
super
|
18
|
+
clear_executed_experiments
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns all of the experiments that have been executed.
|
22
|
+
def executed_experiments
|
23
|
+
ActiveExperiment::Executed.as_array
|
24
|
+
end
|
25
|
+
|
26
|
+
# Clears the list of executed experiments.
|
27
|
+
def clear_executed_experiments
|
28
|
+
ActiveExperiment::Executed.clear_all
|
29
|
+
end
|
30
|
+
|
31
|
+
# Provides the ability to stub an experiment's variant assignment, by
|
32
|
+
# changing the experiment to use the MockRollout within the scope of the
|
33
|
+
# provided block.
|
34
|
+
#
|
35
|
+
# class SubjectExperiment < ActiveExperiment::Base
|
36
|
+
# variant(:red) { "red" }
|
37
|
+
# variant(:blue) { "blue" }
|
38
|
+
# variant(:green) { "green" }
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# Given the above is our experiment, the variant assignment can be stubbed
|
42
|
+
# to always assign the green variant.
|
43
|
+
#
|
44
|
+
# stub_experiment(SubjectExperiment, :green) do
|
45
|
+
# assert_equal "green", SubjectExperiment.run
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# Or an array can be used to have green be assigned for the first run, and
|
49
|
+
# blue for the second, green for the third, and so on.
|
50
|
+
#
|
51
|
+
# stub_experiment(SubjectExperiment, :green, :blue) do
|
52
|
+
# assert_equal "green", SubjectExperiment.run
|
53
|
+
# assert_equal "blue", SubjectExperiment.run
|
54
|
+
# assert_equal "green", SubjectExperiment.run
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# A lambda or proc can be used to provide custom variant assignment using
|
58
|
+
# whatever logic required.
|
59
|
+
#
|
60
|
+
# resolver = lambda do |experiment|
|
61
|
+
# experiment.context[:id] == 42 ? :green : :blue
|
62
|
+
# end
|
63
|
+
#
|
64
|
+
# stub_experiment(SubjectExperiment, resolver) do
|
65
|
+
# assert_equal "blue", SubjectExperiment.run(id: 1)
|
66
|
+
# assert_equal "blue", SubjectExperiment.run(id: 2)
|
67
|
+
# assert_equal "green", SubjectExperiment.run(id: 42)
|
68
|
+
# end
|
69
|
+
#
|
70
|
+
# Options can also be passed, for instance to simulate when an experiment
|
71
|
+
# should be skipped.
|
72
|
+
#
|
73
|
+
# stub_experiment(SubjectExperiment, skip: true) do |mock_rollout|
|
74
|
+
# assert_equal false, mock_rollout.skipped_for('_anything_')
|
75
|
+
# assert_equal :red, mock_rollout.variant_for('_anything_')
|
76
|
+
# assert_nil SubjectExperiment.run
|
77
|
+
# end
|
78
|
+
#
|
79
|
+
# By default the +MockRollout+ class will be used, which implements all of
|
80
|
+
# the functionality described above -- however, any rollout class can be
|
81
|
+
# used for the span of the provided block.
|
82
|
+
#
|
83
|
+
# stub_experiment(SubjectExperiment, rollout_class: MyCustomRollout) do
|
84
|
+
# # ...
|
85
|
+
# end
|
86
|
+
def stub_experiment(experiment_class, *variants, **options)
|
87
|
+
original_rollout = experiment_class.rollout
|
88
|
+
|
89
|
+
rollout_class = options.delete(:rollout_class) || MockRollout
|
90
|
+
rollout_options = { variant: variants }.merge(options)
|
91
|
+
experiment_class.rollout = rollout_class.new(experiment_class, **rollout_options)
|
92
|
+
|
93
|
+
_assert_nothing_raised_or_warn("stub_experiment") do
|
94
|
+
yield experiment_class.rollout
|
95
|
+
end
|
96
|
+
ensure
|
97
|
+
experiment_class.rollout = original_rollout
|
98
|
+
end
|
99
|
+
|
100
|
+
# Asserts that the number of experiments run matches the given number.
|
101
|
+
#
|
102
|
+
# def test_experiments
|
103
|
+
# assert_experiments 0
|
104
|
+
# MyExperiment.run
|
105
|
+
# assert_experiments 1
|
106
|
+
# MyExperiment.run
|
107
|
+
# assert_experiments 2
|
108
|
+
# end
|
109
|
+
#
|
110
|
+
# If a block is provided, that block should cause the specified number of
|
111
|
+
# experiments to be run.
|
112
|
+
#
|
113
|
+
# def test_experiments_again
|
114
|
+
# assert_experiments 1 do
|
115
|
+
# MyExperiment.run
|
116
|
+
# end
|
117
|
+
#
|
118
|
+
# assert_experiments 2 do
|
119
|
+
# MyExperiment.run
|
120
|
+
# MyExperiment.run
|
121
|
+
# end
|
122
|
+
# end
|
123
|
+
def assert_experiments(number, &block)
|
124
|
+
executed_count = executed_experiments.size
|
125
|
+
if block_given?
|
126
|
+
_assert_nothing_raised_or_warn("assert_experiments", &block)
|
127
|
+
executed_count = executed_experiments.size - executed_count
|
128
|
+
assert_equal number, executed_count, "#{number} experiment runs expected, but found #{executed_count}"
|
129
|
+
else
|
130
|
+
assert_equal number, executed_count
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Asserts that no experiments have been run.
|
135
|
+
#
|
136
|
+
# def test_experiments
|
137
|
+
# assert_no_experiments
|
138
|
+
# MyExperiment.run
|
139
|
+
# assert_experiments 1
|
140
|
+
# end
|
141
|
+
#
|
142
|
+
# If a block is passed, that block should not cause any emails to be sent.
|
143
|
+
#
|
144
|
+
# def test_experiments_again
|
145
|
+
# assert_no_experiments do
|
146
|
+
# # No experiments should be run from this block
|
147
|
+
# end
|
148
|
+
# end
|
149
|
+
#
|
150
|
+
# Note: This assertion is simply a shortcut for:
|
151
|
+
#
|
152
|
+
# assert_experiments(0, &block)
|
153
|
+
def assert_no_experiments(&block)
|
154
|
+
assert_experiments(0, &block)
|
155
|
+
end
|
156
|
+
|
157
|
+
# Asserts that a specific experiment has been run, optionally matching args
|
158
|
+
# and/or context.
|
159
|
+
#
|
160
|
+
# def test_experiment
|
161
|
+
# MyExperiment.run(id: 1)
|
162
|
+
# assert_experiment_with MyExperiment, context: { id: 1 }
|
163
|
+
# end
|
164
|
+
#
|
165
|
+
# def test_experiment_with_options
|
166
|
+
# MyExperiment.set(foo: :bar).run(id: 1)
|
167
|
+
# assert_experiment_with MyExperiment, options: { foo: "bar" }
|
168
|
+
# end
|
169
|
+
#
|
170
|
+
# def test_experiment_with_variant
|
171
|
+
# MyExperiment.set(variant: :red).run(id: 1)
|
172
|
+
# assert_experiment_with MyExperiment, variant: :red
|
173
|
+
# end
|
174
|
+
#
|
175
|
+
# If a block is passed, that block should cause the specified experiment to
|
176
|
+
# be run.
|
177
|
+
#
|
178
|
+
# def test_experiment_in_block
|
179
|
+
# assert_experiment_with MyExperiment, context: { id: 1 } do
|
180
|
+
# MyExperiment.run(id: 1)
|
181
|
+
# end
|
182
|
+
# end
|
183
|
+
def assert_experiment_with(experiment_class, context: nil, options: nil, variant: nil, &block)
|
184
|
+
expected = { context: context, options: options, variant: variant }.compact
|
185
|
+
experiments = executed_experiments
|
186
|
+
if block_given?
|
187
|
+
original_executed_experiments = experiments.dup
|
188
|
+
_assert_nothing_raised_or_warn("assert_experiment_with", &block)
|
189
|
+
experiments = executed_experiments - original_executed_experiments
|
190
|
+
end
|
191
|
+
|
192
|
+
match_potential = []
|
193
|
+
match_class = []
|
194
|
+
match_experiment = experiments.find do |experiment|
|
195
|
+
match_potential << experiment
|
196
|
+
if experiment.class == experiment_class
|
197
|
+
match_class << experiment
|
198
|
+
expected.all? do |key, value|
|
199
|
+
experiment.public_send(key) == value
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
message = +"No matching run found for #{experiment_class.name}"
|
205
|
+
message << " with #{expected.inspect}" if expected.any?
|
206
|
+
if match_potential.empty?
|
207
|
+
message << "\n\nNo experiment were run"
|
208
|
+
elsif match_class.empty?
|
209
|
+
message << "\n\nNo #{experiment_class.name} experiments were run, experiments run:"
|
210
|
+
message << "\n #{match_potential.map(&:class).join(", ")}"
|
211
|
+
else
|
212
|
+
message << "\n\nPotential matches:"
|
213
|
+
message << "\n #{match_class.join("\n ")}"
|
214
|
+
end
|
215
|
+
|
216
|
+
assert(match_experiment, message)
|
217
|
+
match_experiment
|
218
|
+
end
|
219
|
+
|
220
|
+
# Mock rollout class that can be used to stub out the variant assignment of
|
221
|
+
# an experiment.
|
222
|
+
#
|
223
|
+
# This is used by the ActiveExperiment::TestHelper in the +stub_experiment+
|
224
|
+
# method. It can be used directly, or through the helper when needing to
|
225
|
+
# stub out the rollout of an experiment in a test. It can also be inherited
|
226
|
+
# and customized.
|
227
|
+
class MockRollout < Rollouts::BaseRollout
|
228
|
+
def initialize(...)
|
229
|
+
@assigned = 0
|
230
|
+
super
|
231
|
+
end
|
232
|
+
|
233
|
+
def skipped_for(ex)
|
234
|
+
raise ArgumentError, "expecting a #{@experiment_class.name}" unless ex.is_a?(@experiment_class)
|
235
|
+
|
236
|
+
skip = opts[:skip]
|
237
|
+
|
238
|
+
# Accepts a callable in the :skip option.
|
239
|
+
return skip.call(ex) if skip.respond_to?(:call)
|
240
|
+
|
241
|
+
# Accepts a boolean in the :skip options, with a default of false.
|
242
|
+
!!skip
|
243
|
+
end
|
244
|
+
|
245
|
+
def variant_for(ex)
|
246
|
+
raise ArgumentError, "expecting a #{@experiment_class.name}" unless ex.is_a?(@experiment_class)
|
247
|
+
|
248
|
+
# Accepts an array in the :variant option.
|
249
|
+
variant = opts[:variant]
|
250
|
+
variant = variant[((@assigned += 1) - 1) % variant.size] if variant.is_a?(Array) && !variant.empty?
|
251
|
+
|
252
|
+
# Accepts a callable in the :variant option.
|
253
|
+
return variant.call(ex) if variant.respond_to?(:call)
|
254
|
+
|
255
|
+
# Accepts a symbol in the :variant option.
|
256
|
+
return variant if variant.is_a?(Symbol)
|
257
|
+
|
258
|
+
# Fall back to the default variant.
|
259
|
+
ex.try(:default_variant)
|
260
|
+
end
|
261
|
+
|
262
|
+
def opts
|
263
|
+
@rollout_options
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveExperiment
|
4
|
+
# == Variant Registration
|
5
|
+
#
|
6
|
+
# Variants are registered using the +control+ and +variant+ methods within an
|
7
|
+
# experiment. The +control+ method is a convenience for registering a variant
|
8
|
+
# with the name +:control+ -- a convention used to describe the default
|
9
|
+
# variant.
|
10
|
+
#
|
11
|
+
# Registering a variant can be done by providing a name, and a block or a
|
12
|
+
# symbol referencing a method.
|
13
|
+
#
|
14
|
+
# class MyExperiment < ActiveExperiment::Base
|
15
|
+
# control { "control" } # defines a variant named :control
|
16
|
+
# variant :treatment, :treatment_method
|
17
|
+
#
|
18
|
+
# private
|
19
|
+
#
|
20
|
+
# def treatment_method
|
21
|
+
# "treatment"
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# Subclassing experiments will inherit variants. Existing variants can be
|
26
|
+
# overridden or added to, and new variants can registered.
|
27
|
+
#
|
28
|
+
# class NewExperiment < MyExperiment
|
29
|
+
# control(override: true) { "new control" }
|
30
|
+
# variant(:treatment, add: true, prepend: true) { "new treatment" }
|
31
|
+
# variant(:new_variant) { "new variant" }
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# In the above example, the control is overridden, a new variant is added,
|
35
|
+
# and a new step is added to the treatment variant.
|
36
|
+
#
|
37
|
+
# When running an experiment, variants can be overridden by using the +on+
|
38
|
+
# method. This allows utilizing the scope of where the experiment is run to
|
39
|
+
# change the experiment behavior.
|
40
|
+
#
|
41
|
+
# NewExperiment.run do |experiment|
|
42
|
+
# experiment.on(:treatment) { "overridden treatment" }
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# By default, the "control" variant is assigned as the default variant, but
|
46
|
+
# any variant can be specified as the default. The concept of the control
|
47
|
+
# variant is only a convention.
|
48
|
+
#
|
49
|
+
# To specify a different default variant:
|
50
|
+
#
|
51
|
+
# class MyExperiment < ActiveExperiment::Base
|
52
|
+
# variant(:red) { "red" }
|
53
|
+
# variant(:blue) { "blue" }
|
54
|
+
#
|
55
|
+
# use_default_variant :blue
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# The default variant is assigned if the experiment is skipped or if no other
|
59
|
+
# variant has been resolved after asking the rollout -- when the rollout may
|
60
|
+
# not be working properly.
|
61
|
+
module Variants
|
62
|
+
extend ActiveSupport::Concern
|
63
|
+
include ActiveSupport::Callbacks
|
64
|
+
|
65
|
+
VARIANT_CHAIN_SUFFIX = "_variant"
|
66
|
+
private_constant :VARIANT_CHAIN_SUFFIX
|
67
|
+
|
68
|
+
STEP_CHAIN_SUFFIX = "_steps"
|
69
|
+
private_constant :STEP_CHAIN_SUFFIX
|
70
|
+
|
71
|
+
included do
|
72
|
+
class_attribute :variants, instance_writer: false, instance_predicate: false, default: {}
|
73
|
+
class_attribute :default_variant, instance_writer: false, instance_predicate: false, default: :control
|
74
|
+
end
|
75
|
+
|
76
|
+
# These methods will be included into any Active Experiment object, adding
|
77
|
+
# the the ability to set the default variant, defining variants and their
|
78
|
+
# behaviors and callback helpers.
|
79
|
+
module ClassMethods
|
80
|
+
private
|
81
|
+
def use_default_variant(variant)
|
82
|
+
variant = variant.to_sym
|
83
|
+
raise ArgumentError, "Unknown #{variant.inspect} variant" unless variants[variant]
|
84
|
+
|
85
|
+
self.default_variant = variant
|
86
|
+
end
|
87
|
+
|
88
|
+
def register_variant_callback(variant, *filters, override: false, add: false, **options, &block)
|
89
|
+
raise ArgumentError, "Provide either `override: true` or `add: true` but not both" if override && add
|
90
|
+
|
91
|
+
variant = variant.to_sym
|
92
|
+
if variants[variant].present?
|
93
|
+
unless override || add
|
94
|
+
raise ArgumentError, "The #{variant.inspect} variant is already registered. " \
|
95
|
+
"Provide `override: true` or `add: true` to make changes to it."
|
96
|
+
end
|
97
|
+
elsif override || add
|
98
|
+
raise ArgumentError, "Unable to override or add to unknown #{variant.inspect} variant"
|
99
|
+
end
|
100
|
+
|
101
|
+
self.variants = variants.dup unless singleton_class.method_defined?(:variants, false)
|
102
|
+
|
103
|
+
variants[variant] = callback_chain = :"#{variant}#{VARIANT_CHAIN_SUFFIX}"
|
104
|
+
|
105
|
+
unless add
|
106
|
+
define_variant_callbacks(callback_chain) # variant callback chain
|
107
|
+
define_variant_callbacks("#{callback_chain}#{STEP_CHAIN_SUFFIX}") # variant step chain
|
108
|
+
end
|
109
|
+
|
110
|
+
filters.push(block) if block.present?
|
111
|
+
filters.unshift(callback_chain) if filters.empty?
|
112
|
+
set_callback_with_target("#{callback_chain}#{STEP_CHAIN_SUFFIX}", *filters, **options) do |target, callback|
|
113
|
+
target.instance_variable_set(:@results, callback.call(target, nil))
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def define_variant_callbacks(callback_chain)
|
118
|
+
define_callbacks(callback_chain)
|
119
|
+
private :"_#{callback_chain}_callbacks", :"_run_#{callback_chain}_callbacks"
|
120
|
+
end
|
121
|
+
|
122
|
+
def set_variant_callback(variant, type, *filters, &block)
|
123
|
+
raise ArgumentError, "Unknown `#{variant}` variant" unless variants[variant.to_sym]
|
124
|
+
|
125
|
+
set_callback("#{variant}#{VARIANT_CHAIN_SUFFIX}", type, *filters, &block)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# The names for all registered variants.
|
130
|
+
#
|
131
|
+
# This is most commonly used by rollouts, where knowing the variant names
|
132
|
+
# is important for determining which variant to assign.
|
133
|
+
def variant_names
|
134
|
+
variants.keys
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
def variant_step_chains
|
139
|
+
@variant_step_chains ||= variants.transform_values do |callback_chain|
|
140
|
+
chain_name = "#{callback_chain}#{STEP_CHAIN_SUFFIX}"
|
141
|
+
-> { run_callbacks(chain_name, :process_variant_steps) { @results } }
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "global_id"
|
4
|
+
require "active_support"
|
5
|
+
require "active_support/rails"
|
6
|
+
require "active_support/tagged_logging"
|
7
|
+
|
8
|
+
require "active_experiment/version"
|
9
|
+
|
10
|
+
module ActiveExperiment
|
11
|
+
Error = Class.new(StandardError)
|
12
|
+
ExecutionError = Class.new(Error)
|
13
|
+
|
14
|
+
extend ActiveSupport::Autoload
|
15
|
+
|
16
|
+
autoload :Base
|
17
|
+
autoload :Cache
|
18
|
+
autoload :ConfiguredExperiment
|
19
|
+
autoload :Executed
|
20
|
+
autoload :Rollouts
|
21
|
+
autoload :Capturable
|
22
|
+
|
23
|
+
autoload :TestCase
|
24
|
+
autoload :TestHelper
|
25
|
+
|
26
|
+
mattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))
|
27
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This file is here to make the gem behave like a Rails core library...
|
4
|
+
require "active_experiment"
|
5
|
+
|
6
|
+
# But we'll also require the railtie if it looks like it's loading in a Rails
|
7
|
+
# app, to avoid having to require it manually in application.rb.
|
8
|
+
require "active_experiment/railtie" if defined?(Rails)
|
@@ -0,0 +1,12 @@
|
|
1
|
+
Description:
|
2
|
+
Generates a new experiment. Pass the experiment name, either CamelCased or
|
3
|
+
under_scored, with or without the experiment postfix. Optionally, provide a
|
4
|
+
list of variant names.
|
5
|
+
|
6
|
+
Examples:
|
7
|
+
`bin/rails generate experiment RedBlueExperiment red blue`
|
8
|
+
|
9
|
+
Creates the the following files:
|
10
|
+
|
11
|
+
Experiment: app/experiments/red_blue_experiment.rb
|
12
|
+
Test: test/experiments/red_blue_experiment_test.rb
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/named_base"
|
4
|
+
|
5
|
+
module Rails # :nodoc:
|
6
|
+
module Generators # :nodoc:
|
7
|
+
class ExperimentGenerator < Rails::Generators::NamedBase # :nodoc:
|
8
|
+
class_option :parent, type: :string, default: "ApplicationExperiment", desc: "The parent class for the generated experiment"
|
9
|
+
class_option :skip_comments, type: :boolean, default: false, desc: "Omit helpful comments from generated files"
|
10
|
+
|
11
|
+
argument :variants, type: :array, default: %w[control treatment], banner: "variant variant"
|
12
|
+
|
13
|
+
check_class_collision suffix: "Experiment"
|
14
|
+
|
15
|
+
hook_for :test_framework
|
16
|
+
|
17
|
+
def self.default_generator_root
|
18
|
+
__dir__
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_experiment_file
|
22
|
+
template "experiment.rb", File.join("app/experiments", class_path, "#{file_name}_experiment.rb")
|
23
|
+
|
24
|
+
in_root do
|
25
|
+
if behavior == :invoke && !File.exist?(application_experiment_file_name)
|
26
|
+
template "application_experiment.rb", application_experiment_file_name
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
def parent_class_name
|
33
|
+
options[:parent]
|
34
|
+
end
|
35
|
+
|
36
|
+
def variant_names
|
37
|
+
@variant_names ||= variants.map { |variant| variant.to_s.underscore }
|
38
|
+
end
|
39
|
+
|
40
|
+
def file_name
|
41
|
+
@_file_name ||= super.sub(/_experiment\z/i, "")
|
42
|
+
end
|
43
|
+
|
44
|
+
def application_experiment_file_name
|
45
|
+
@application_experiment_file_name ||= if mountable_engine?
|
46
|
+
"app/experiments/#{namespaced_path}/application_experiment.rb"
|
47
|
+
else
|
48
|
+
"app/experiments/application_experiment.rb"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
<% module_namespacing do -%>
|
2
|
+
class <%= class_name %>Experiment < <%= parent_class_name.classify %>
|
3
|
+
<% variant_names.each do |variant| -%>
|
4
|
+
<% if variant == "control" -%>
|
5
|
+
<%= variant -%> { }
|
6
|
+
<% else -%>
|
7
|
+
variant(:<%= variant -%>) { }
|
8
|
+
<% end -%>
|
9
|
+
<% end -%>
|
10
|
+
|
11
|
+
<% unless options[:skip_comments] -%>
|
12
|
+
<% unless variant_names.include?("control") -%>
|
13
|
+
# Specify a default variant to use when the experiment is skipped:
|
14
|
+
#use_default_variant :<%= variant_names.first %>
|
15
|
+
#
|
16
|
+
<% end -%>
|
17
|
+
# Run this experiment by providing a context (current_user in this example),
|
18
|
+
# and optionally override the variant behaviors:
|
19
|
+
#
|
20
|
+
# <%= class_name %>Experiment.run(current_user) do |experiment|
|
21
|
+
<% variant_names.each do |variant| -%>
|
22
|
+
# experiment.on(:<%= variant -%>) { "overridden <%= variant -%>" }
|
23
|
+
<% end -%>
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# Each context (user) will consistently have the same variant assigned. More
|
27
|
+
# advanced logic can be provided to segment contexts into specific variants:
|
28
|
+
#
|
29
|
+
#segment :old_accounts, into: :<%= variant_names.last %>
|
30
|
+
#def old_accounts
|
31
|
+
# context.created_at < 1.year.ago
|
32
|
+
#end
|
33
|
+
<% end -%>
|
34
|
+
end
|
35
|
+
<% end -%>
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# require "generators/rspec"
|
4
|
+
|
5
|
+
module Rspec # :nodoc:
|
6
|
+
module Generators # :nodoc:
|
7
|
+
class ExperimentGenerator < Rails::Generators::NamedBase # :nodoc:
|
8
|
+
source_root(File.expand_path("templates", __dir__))
|
9
|
+
|
10
|
+
def create_spec_file
|
11
|
+
template "experiment_spec.rb", File.join("spec/experiments", class_path, "#{file_name}_experiment_spec.rb")
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
def file_name
|
16
|
+
@_file_name ||= super.sub(/_experiment\z/i, "")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/test_unit"
|
4
|
+
|
5
|
+
module TestUnit # :nodoc:
|
6
|
+
module Generators # :nodoc:
|
7
|
+
class ExperimentGenerator < Base # :nodoc:
|
8
|
+
check_class_collision suffix: "ExperimentTest"
|
9
|
+
source_root(File.expand_path("templates", __dir__))
|
10
|
+
|
11
|
+
def create_test_file
|
12
|
+
template "experiment_test.rb", File.join("test/experiments", class_path, "#{file_name}_experiment_test.rb")
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
def file_name
|
17
|
+
@_file_name ||= super.sub(/_experiment\z/i, "")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|