activeexperiment 0.1.0.alpha
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 +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
|