vanity 1.8.4 → 1.9.0.beta

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. data/.travis.yml +3 -2
  2. data/CHANGELOG +12 -0
  3. data/Gemfile +6 -3
  4. data/Gemfile.lock +12 -10
  5. data/README.rdoc +45 -16
  6. data/Rakefile +14 -9
  7. data/doc/_layouts/page.html +4 -6
  8. data/doc/ab_testing.textile +1 -1
  9. data/doc/configuring.textile +2 -4
  10. data/doc/email.textile +1 -3
  11. data/doc/index.textile +3 -63
  12. data/doc/rails.textile +34 -8
  13. data/gemfiles/rails3.gemfile +12 -3
  14. data/gemfiles/rails3.gemfile.lock +37 -11
  15. data/gemfiles/rails31.gemfile +12 -3
  16. data/gemfiles/rails31.gemfile.lock +37 -11
  17. data/gemfiles/rails32.gemfile +12 -3
  18. data/gemfiles/rails32.gemfile.lock +37 -11
  19. data/gemfiles/rails4.gemfile +12 -3
  20. data/gemfiles/rails4.gemfile.lock +37 -11
  21. data/lib/vanity/adapters/abstract_adapter.rb +4 -0
  22. data/lib/vanity/adapters/active_record_adapter.rb +18 -10
  23. data/lib/vanity/adapters/mock_adapter.rb +8 -4
  24. data/lib/vanity/adapters/mongodb_adapter.rb +11 -7
  25. data/lib/vanity/adapters/redis_adapter.rb +88 -37
  26. data/lib/vanity/commands/report.rb +9 -9
  27. data/lib/vanity/experiment/ab_test.rb +120 -101
  28. data/lib/vanity/experiment/alternative.rb +21 -21
  29. data/lib/vanity/experiment/base.rb +5 -5
  30. data/lib/vanity/experiment/bayesian_bandit_score.rb +51 -51
  31. data/lib/vanity/experiment/definition.rb +10 -10
  32. data/lib/vanity/frameworks/rails.rb +39 -36
  33. data/lib/vanity/helpers.rb +6 -4
  34. data/lib/vanity/metric/active_record.rb +1 -1
  35. data/lib/vanity/metric/base.rb +23 -24
  36. data/lib/vanity/metric/google_analytics.rb +5 -5
  37. data/lib/vanity/playground.rb +118 -24
  38. data/lib/vanity/templates/_report.erb +20 -6
  39. data/lib/vanity/templates/vanity.css +2 -0
  40. data/lib/vanity/version.rb +1 -1
  41. data/test/adapters/redis_adapter_test.rb +106 -1
  42. data/test/dummy/config/database.yml +21 -4
  43. data/test/dummy/config/routes.rb +1 -1
  44. data/test/experiment/ab_test.rb +93 -13
  45. data/test/metric/active_record_test.rb +9 -4
  46. data/test/passenger_test.rb +43 -42
  47. data/test/playground_test.rb +50 -1
  48. data/test/rails_dashboard_test.rb +38 -1
  49. data/test/rails_helper_test.rb +5 -0
  50. data/test/rails_test.rb +66 -15
  51. data/test/test_helper.rb +24 -2
  52. data/vanity.gemspec +0 -2
  53. metadata +45 -57
@@ -24,3 +24,5 @@
24
24
  .vanity form#milestones label { margin-right: .5em }
25
25
  .vanity form#milestones input { vertical-align: bottom }
26
26
  .vanity .metric .marking.label { position: absolute; bottom: 2em; color: #c66; font-size: 80% }
27
+
28
+ .vanity .alert { padding: 8px 35px 8px 14px; margin-bottom: 20px; text-shadow: 0px 1px 0px rgba(255, 255, 255, 0.5); background-color: rgb(252, 248, 227); border: 1px solid rgb(251, 238, 213); border-radius: 4px 4px 4px 4px; }
@@ -1,5 +1,5 @@
1
1
  module Vanity
2
- VERSION = "1.8.4"
2
+ VERSION = "1.9.0.beta"
3
3
 
4
4
  module Version
5
5
  version = VERSION.to_s.split(".").map { |i| i.to_i }
@@ -1,6 +1,12 @@
1
1
  require 'test/test_helper'
2
2
 
3
3
  class RedisAdapterTest < Test::Unit::TestCase
4
+ def setup
5
+ require "vanity/adapters/redis_adapter"
6
+ require "redis"
7
+ require "redis/namespace"
8
+ end
9
+
4
10
  def test_warn_on_disconnect_error
5
11
  if defined?(Redis)
6
12
  assert_nothing_raised do
@@ -8,10 +14,109 @@ class RedisAdapterTest < Test::Unit::TestCase
8
14
  mocked_redis = stub("Redis")
9
15
  mocked_redis.expects(:client).raises(RuntimeError)
10
16
  redis_adapter = Vanity::Adapters::RedisAdapter.new({})
11
- redis_adapter.expects(:warn).with("Error while disconnecting from redis: RuntimeError")
12
17
  redis_adapter.stubs(:redis).returns(mocked_redis)
18
+ redis_adapter.expects(:warn).with("Error while disconnecting from redis: RuntimeError")
13
19
  redis_adapter.disconnect!
14
20
  end
15
21
  end
16
22
  end
23
+
24
+ def stub_redis
25
+ Vanity.playground.failover_on_datastore_error!
26
+ mocked_redis = stub("Redis")
27
+ redis_adapter = Vanity::Adapters::RedisAdapter.new(:redis => mocked_redis)
28
+
29
+ [redis_adapter, mocked_redis]
30
+ end
31
+
32
+ def test_graceful_failure_metric_track
33
+ redis_adapter, mocked_redis = stub_redis
34
+ mocked_redis.stubs(:incrby).raises(RuntimeError)
35
+
36
+ assert_nothing_raised do
37
+ redis_adapter.metric_track("metric", Time.now.to_s, "3ff62e2fb51f0b22646a342a2d357aec", [1])
38
+ end
39
+ end
40
+
41
+ def test_graceful_failure_set_experiment_created_at
42
+ redis_adapter, mocked_redis = stub_redis
43
+ mocked_redis.stubs(:setnx).raises(RuntimeError)
44
+
45
+ assert_nothing_raised do
46
+ redis_adapter.set_experiment_created_at("price_options", Time.now)
47
+ end
48
+ end
49
+
50
+ def test_graceful_failure_is_experiment_completed?
51
+ redis_adapter, mocked_redis = stub_redis
52
+ mocked_redis.stubs(:exists).raises(RuntimeError)
53
+
54
+ assert_nothing_raised do
55
+ redis_adapter.is_experiment_completed?("price_options")
56
+ end
57
+ end
58
+
59
+ def test_graceful_failure_ab_show
60
+ redis_adapter, mocked_redis = stub_redis
61
+ mocked_redis.stubs(:[]=).raises(RuntimeError)
62
+
63
+ assert_nothing_raised do
64
+ redis_adapter.ab_show("price_options", "3ff62e2fb51f0b22646a342a2d357aec", 0)
65
+ end
66
+ end
67
+
68
+ def test_graceful_failure_ab_showing
69
+ redis_adapter, mocked_redis = stub_redis
70
+ mocked_redis.stubs(:[]).raises(RuntimeError)
71
+
72
+ assert_nothing_raised do
73
+ redis_adapter.ab_showing("price_options", "3ff62e2fb51f0b22646a342a2d357aec")
74
+ end
75
+ end
76
+
77
+ def test_graceful_failure_ab_not_showing
78
+ redis_adapter, mocked_redis = stub_redis
79
+ mocked_redis.stubs(:del).raises(RuntimeError)
80
+
81
+ assert_nothing_raised do
82
+ redis_adapter.ab_not_showing("price_options", "3ff62e2fb51f0b22646a342a2d357aec")
83
+ end
84
+ end
85
+
86
+ def test_graceful_failure_ab_add_participant
87
+ redis_adapter, mocked_redis = stub_redis
88
+ mocked_redis.stubs(:sadd).raises(RuntimeError)
89
+
90
+ assert_nothing_raised do
91
+ redis_adapter.ab_add_participant("price_options", "3ff62e2fb51f0b22646a342a2d357aec", 0)
92
+ end
93
+ end
94
+
95
+ def test_graceful_failure_ab_seen
96
+ redis_adapter, mocked_redis = stub_redis
97
+ mocked_redis.stubs(:sismember).raises(RuntimeError)
98
+
99
+ assert_nothing_raised do
100
+ redis_adapter.ab_seen("price_options", "3ff62e2fb51f0b22646a342a2d357aec", 0)
101
+ end
102
+ end
103
+
104
+ def test_graceful_failure_ab_assigned
105
+ redis_adapter, mocked_redis = stub_redis
106
+ mocked_redis.stubs(:sismember).raises(RuntimeError)
107
+
108
+ assert_nothing_raised do
109
+ redis_adapter.ab_assigned("price_options", "3ff62e2fb51f0b22646a342a2d357aec")
110
+ end
111
+ end
112
+
113
+ def test_graceful_failure_ab_add_conversion
114
+ redis_adapter, mocked_redis = stub_redis
115
+ mocked_redis.stubs(:sismember).raises(RuntimeError)
116
+
117
+ assert_nothing_raised do
118
+ redis_adapter.ab_add_conversion("price_options", 0, "3ff62e2fb51f0b22646a342a2d357aec")
119
+ end
120
+ end
121
+
17
122
  end
@@ -1,5 +1,22 @@
1
+ # Based on https://github.com/plataformatec/devise/blob/master/test/rails_app/config/database.yml
2
+
3
+ # SQLite version 3.x
4
+ # gem install sqlite3
5
+ #
6
+ # Ensure the SQLite 3 gem is defined in your Gemfile
7
+ # gem 'sqlite3'
8
+
9
+ # Set databases in all environments since we may test loading Rails in non-
10
+ # test environments
11
+
12
+ development:
13
+ adapter: sqlite3
14
+ database: ":memory:"
15
+
1
16
  test:
2
- adapter: mysql
3
- database: vanity_test
4
- pool: 5
5
- timeout: 5000
17
+ adapter: sqlite3
18
+ database: ":memory:"
19
+
20
+ production:
21
+ adapter: sqlite3
22
+ database: ":memory:"
@@ -54,5 +54,5 @@ Dummy::Application.routes.draw do
54
54
 
55
55
  # This is a legacy wild controller route that's not recommended for RESTful applications.
56
56
  # Note: This route will make all actions in every controller accessible via GET requests.
57
- match ':controller(/:action(/:id(.:format)))', :via => [:get]
57
+ match ':controller(/:action(/:id(.:format)))', :via => [:get, :post]
58
58
  end
@@ -133,7 +133,7 @@ class AbTestTest < ActionController::TestCase
133
133
 
134
134
  # -- use_js! --
135
135
 
136
- def test_does_not_record_participant_when_using_js
136
+ def test_choose_does_not_record_participant_when_using_js
137
137
  Vanity.playground.use_js!
138
138
  ids = (0...10).to_a
139
139
  new_ab_test :foobar do
@@ -160,6 +160,44 @@ class AbTestTest < ActionController::TestCase
160
160
  assert_equal 1, on_assignment_called_times
161
161
  end
162
162
 
163
+ def test_calls_on_assignment_when_given_valid_request
164
+ on_assignment_called_times = 0
165
+ new_ab_test :foobar do
166
+ alternatives "foo", "bar"
167
+ identify { "6e98ec" }
168
+ metrics :coolness
169
+ on_assignment { on_assignment_called_times = on_assignment_called_times+1 }
170
+ end
171
+ experiment(:foobar).choose(dummy_request)
172
+ assert_equal 1, on_assignment_called_times
173
+ end
174
+
175
+ def test_does_not_call_on_assignment_when_given_invalid_request
176
+ on_assignment_called_times = 0
177
+ new_ab_test :foobar do
178
+ alternatives "foo", "bar"
179
+ identify { "6e98ec" }
180
+ metrics :coolness
181
+ on_assignment { on_assignment_called_times = on_assignment_called_times+1 }
182
+ end
183
+ request = dummy_request
184
+ request.user_agent = "Googlebot/2.1 ( http://www.google.com/bot.html)"
185
+ experiment(:foobar).choose(request)
186
+ assert_equal 0, on_assignment_called_times
187
+ end
188
+
189
+ def test_calls_on_assignment_on_new_assignment_via_chooses
190
+ on_assignment_called_times = 0
191
+ new_ab_test :foobar do
192
+ alternatives "foo", "bar"
193
+ identify { "6e98ec" }
194
+ metrics :coolness
195
+ on_assignment { on_assignment_called_times = on_assignment_called_times+1 }
196
+ end
197
+ 2.times { experiment(:foobar).chooses("foo") }
198
+ assert_equal 1, on_assignment_called_times
199
+ end
200
+
163
201
  def test_returns_the_same_alternative_consistently_when_on_assignment_is_set
164
202
  new_ab_test :foobar do
165
203
  alternatives "foo", "bar"
@@ -344,6 +382,27 @@ class AbTestTest < ActionController::TestCase
344
382
  assert_equal 100, alts.map(&:converted).sum
345
383
  end
346
384
 
385
+ def test_choose_records_participants_given_a_valid_request
386
+ new_ab_test :foobar do
387
+ alternatives "foo", "bar"
388
+ identify { "me" }
389
+ metrics :coolness
390
+ end
391
+ experiment(:foobar).choose(dummy_request)
392
+ assert_equal 1, experiment(:foobar).alternatives.map(&:participants).sum
393
+ end
394
+
395
+ def test_choose_ignores_participants_given_an_invalid_request
396
+ new_ab_test :foobar do
397
+ alternatives "foo", "bar"
398
+ identify { "me" }
399
+ metrics :coolness
400
+ end
401
+ request = dummy_request
402
+ request.user_agent = "Googlebot/2.1 ( http://www.google.com/bot.html)"
403
+ experiment(:foobar).choose(request)
404
+ assert_equal 0, experiment(:foobar).alternatives.map(&:participants).sum
405
+ end
347
406
 
348
407
  def test_destroy_experiment
349
408
  new_ab_test :simple do
@@ -465,6 +524,7 @@ class AbTestTest < ActionController::TestCase
465
524
 
466
525
 
467
526
  # -- Scoring --
527
+
468
528
  def test_calculate_score
469
529
  new_ab_test :abcd do
470
530
  alternatives :a, :b, :c, :d
@@ -488,10 +548,10 @@ class AbTestTest < ActionController::TestCase
488
548
  metrics :coolness
489
549
  end
490
550
  # participating, conversions, rate, z-score
491
- # Control: 182 35 19.23% N/A
492
- # Treatment A: 180 45 25.00% 1.33
493
- # treatment B: 189 28 14.81% -1.13
494
- # treatment C: 188 61 32.45% 2.94
551
+ # Control: 182 35 19.23% N/A
552
+ # Treatment A: 180 45 25.00% 1.33
553
+ # treatment B: 189 28 14.81% -1.13
554
+ # treatment C: 188 61 32.45% 2.94
495
555
  fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
496
556
 
497
557
  z_scores = experiment(:abcd).score.alts.map { |alt| "%.2f" % alt.z_score }
@@ -514,10 +574,10 @@ class AbTestTest < ActionController::TestCase
514
574
  metrics :coolness
515
575
  end
516
576
  # participating, conversions, rate, z-score
517
- # Control: 182 35 19.23% N/A
518
- # Treatment A: 180 45 25.00% 1.33
519
- # treatment B: 189 28 14.81% -1.13
520
- # treatment C: 188 61 32.45% 2.94
577
+ # Control: 182 35 19.23% N/A
578
+ # Treatment A: 180 45 25.00% 1.33
579
+ # treatment B: 189 28 14.81% -1.13
580
+ # treatment C: 188 61 32.45% 2.94
521
581
  fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
522
582
 
523
583
  score_result = experiment(:abcd).bayes_bandit_score
@@ -593,10 +653,10 @@ class AbTestTest < ActionController::TestCase
593
653
  metrics :coolness
594
654
  end
595
655
  # participating, conversions, rate, z-score
596
- # Control: 182 35 19.23% N/A
597
- # Treatment A: 180 45 25.00% 1.33
598
- # treatment B: 189 28 14.81% -1.13
599
- # treatment C: 188 61 32.45% 2.94
656
+ # Control: 182 35 19.23% N/A
657
+ # Treatment A: 180 45 25.00% 1.33
658
+ # treatment B: 189 28 14.81% -1.13
659
+ # treatment C: 188 61 32.45% 2.94
600
660
  fake :abcd, :a=>[182, 35], :b=>[180, 45], :c=>[189,28], :d=>[188, 61]
601
661
 
602
662
  assert_equal <<-TEXT, experiment(:abcd).conclusion.join("\n") << "\n"
@@ -919,6 +979,26 @@ This experiment did not run long enough to find a clear winner.
919
979
  assert_equal experiment(:simple).alternatives[2].participants, 1
920
980
  end
921
981
 
982
+ def test_chooses_records_participants_given_a_valid_request
983
+ new_ab_test :simple do
984
+ alternatives :a, :b, :c
985
+ metrics :coolness
986
+ end
987
+ experiment(:simple).chooses(:a, dummy_request)
988
+ assert_equal 1, experiment(:simple).alternatives[0].participants
989
+ end
990
+
991
+ def test_chooses_ignores_participants_given_an_invalid_request
992
+ new_ab_test :simple do
993
+ alternatives :a, :b, :c
994
+ metrics :coolness
995
+ end
996
+ request = dummy_request
997
+ request.user_agent = "Googlebot/2.1 ( http://www.google.com/bot.html)"
998
+ experiment(:simple).chooses(:a, request)
999
+ assert_equal 0, experiment(:simple).alternatives[0].participants
1000
+ end
1001
+
922
1002
  def test_no_collection_and_chooses
923
1003
  not_collecting!
924
1004
  new_ab_test :simple do
@@ -1,10 +1,12 @@
1
1
  require "test/test_helper"
2
2
 
3
3
  class Sky < ActiveRecord::Base
4
- connection.drop_table :skies if table_exists?
5
- connection.create_table :skies do |t|
6
- t.integer :height
7
- t.timestamps
4
+ if connected?
5
+ connection.drop_table :skies if table_exists?
6
+ connection.create_table :skies do |t|
7
+ t.integer :height
8
+ t.timestamps
9
+ end
8
10
  end
9
11
 
10
12
  if defined?(Rails::Railtie)
@@ -14,6 +16,7 @@ class Sky < ActiveRecord::Base
14
16
  end
15
17
  end
16
18
 
19
+ if ActiveRecord::Base.connected?
17
20
 
18
21
  context "ActiveRecord Metric" do
19
22
 
@@ -305,3 +308,5 @@ context "ActiveRecord Metric" do
305
308
  end
306
309
  end
307
310
  end
311
+
312
+ end
@@ -1,51 +1,52 @@
1
1
  require "test/test_helper"
2
2
 
3
- #not supported for rails3
4
- unless defined?(Rails::Railtie)
5
- require "phusion_passenger/spawn_manager"
6
- class PassengerTest < Test::Unit::TestCase
7
- def setup
8
- super
9
- ActiveRecord::Base.connection.disconnect! # Otherwise AR metric tests fail
10
- @original = Vanity.playground.connection
11
- File.unlink "test/myapp/config/vanity.yml" rescue nil
12
- File.open("test/myapp/config/vanity.yml", "w") do |io|
13
- io.write YAML.dump({ "production"=>DATABASE })
3
+ # Not supported for rails3
4
+ if !defined?(Rails::Railtie) && ActiveRecord::Base.connected?
5
+ require "phusion_passenger/spawn_manager"
6
+
7
+ class PassengerTest < Test::Unit::TestCase
8
+ def setup
9
+ super
10
+ ActiveRecord::Base.connection.disconnect! # Otherwise AR metric tests fail
11
+ @original = Vanity.playground.connection
12
+ File.unlink "test/myapp/config/vanity.yml" rescue nil
13
+ File.open("test/myapp/config/vanity.yml", "w") do |io|
14
+ io.write YAML.dump({ "production"=>DATABASE })
15
+ end
16
+ @server = PhusionPassenger::SpawnManager.new
17
+ @server.start
18
+ Thread.pass until @server.started?
19
+ app_root = File.expand_path("myapp", File.dirname(__FILE__))
20
+ @app = @server.spawn_application "app_root"=>app_root, "spawn_method"=>"smart"
14
21
  end
15
- @server = PhusionPassenger::SpawnManager.new
16
- @server.start
17
- Thread.pass until @server.started?
18
- app_root = File.expand_path("myapp", File.dirname(__FILE__))
19
- @app = @server.spawn_application "app_root"=>app_root, "spawn_method"=>"smart"
20
- end
21
22
 
22
- def test_reconnect
23
- # When using AR adapter, we're not responsible to reconnect, and we're going
24
- # to get the same "connect" (AR connection handler) either way.
25
- # return if defined?(Vanity::Adapters::ActiveRecordAdapter) && Vanity::Adapters::ActiveRecordAdapter === Vanity.playground.connection
23
+ def test_reconnect
24
+ # When using AR adapter, we're not responsible to reconnect, and we're going
25
+ # to get the same "connect" (AR connection handler) either way.
26
+ # return if defined?(Vanity::Adapters::ActiveRecordAdapter) && Vanity::Adapters::ActiveRecordAdapter === Vanity.playground.connection
26
27
 
27
- sleep 0.1
28
- case @app.listen_socket_type
29
- when "tcp" ; socket = TCPSocket.new(*@app.listen_socket_name.split(":"))
30
- when "unix"; socket = UNIXSocket.new(@app.listen_socket_name)
31
- else fail
28
+ sleep 0.1
29
+ case @app.listen_socket_type
30
+ when "tcp" ; socket = TCPSocket.new(*@app.listen_socket_name.split(":"))
31
+ when "unix"; socket = UNIXSocket.new(@app.listen_socket_name)
32
+ else fail
33
+ end
34
+ channel = PhusionPassenger::MessageChannel.new(socket)
35
+ request = {"REQUEST_PATH"=>"/", "REQUEST_METHOD"=>"GET", "QUERY_STRING"=>" "}
36
+ channel.write_scalar request.to_a.join("\0")
37
+ response = socket.read.split("\r\n\r\n").last
38
+ socket.close
39
+ conn, obj_id = response.split("\n")
40
+ assert_equal @original.to_s, conn
41
+ assert_not_equal @original.object_id.to_s, obj_id
32
42
  end
33
- channel = PhusionPassenger::MessageChannel.new(socket)
34
- request = {"REQUEST_PATH"=>"/", "REQUEST_METHOD"=>"GET", "QUERY_STRING"=>" "}
35
- channel.write_scalar request.to_a.join("\0")
36
- response = socket.read.split("\r\n\r\n").last
37
- socket.close
38
- conn, obj_id = response.split("\n")
39
- assert_equal @original.to_s, conn
40
- assert_not_equal @original.object_id.to_s, obj_id
41
- end
42
43
 
43
- def teardown
44
- super
45
- @server.cleanup
46
- @server.stop
47
- Process.kill('SIGKILL', @app.pid.to_i) # Just in case...KIDS, GET OUT OF THE POOL!
48
- File.unlink "test/myapp/config/vanity.yml"
44
+ def teardown
45
+ super
46
+ @server.cleanup
47
+ @server.stop
48
+ Process.kill('SIGKILL', @app.pid.to_i) # Just in case...KIDS, GET OUT OF THE POOL!
49
+ File.unlink "test/myapp/config/vanity.yml"
50
+ end
49
51
  end
50
52
  end
51
- end