├── .codeclimate.yml ├── .csslintrc ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── split.rb └── split │ ├── algorithms.rb │ ├── algorithms │ ├── block_randomization.rb │ ├── weighted_sample.rb │ └── whiplash.rb │ ├── alternative.rb │ ├── cache.rb │ ├── combined_experiments_helper.rb │ ├── configuration.rb │ ├── dashboard.rb │ ├── dashboard │ ├── helpers.rb │ ├── pagination_helpers.rb │ ├── paginator.rb │ ├── public │ │ ├── dashboard-filtering.js │ │ ├── dashboard.js │ │ ├── jquery-1.11.1.min.js │ │ ├── reset.css │ │ └── style.css │ └── views │ │ ├── _controls.erb │ │ ├── _experiment.erb │ │ ├── _experiment_with_goal_header.erb │ │ ├── index.erb │ │ └── layout.erb │ ├── encapsulated_helper.rb │ ├── engine.rb │ ├── exceptions.rb │ ├── experiment.rb │ ├── experiment_catalog.rb │ ├── extensions │ └── string.rb │ ├── goals_collection.rb │ ├── helper.rb │ ├── metric.rb │ ├── persistence.rb │ ├── persistence │ ├── cookie_adapter.rb │ ├── dual_adapter.rb │ ├── redis_adapter.rb │ └── session_adapter.rb │ ├── redis_interface.rb │ ├── trial.rb │ ├── user.rb │ ├── version.rb │ └── zscore.rb ├── spec ├── algorithms │ ├── block_randomization_spec.rb │ ├── weighted_sample_spec.rb │ └── whiplash_spec.rb ├── alternative_spec.rb ├── cache_spec.rb ├── combined_experiments_helper_spec.rb ├── configuration_spec.rb ├── dashboard │ ├── pagination_helpers_spec.rb │ └── paginator_spec.rb ├── dashboard_helpers_spec.rb ├── dashboard_spec.rb ├── encapsulated_helper_spec.rb ├── experiment_catalog_spec.rb ├── experiment_spec.rb ├── goals_collection_spec.rb ├── helper_spec.rb ├── metric_spec.rb ├── persistence │ ├── cookie_adapter_spec.rb │ ├── dual_adapter_spec.rb │ ├── redis_adapter_spec.rb │ └── session_adapter_spec.rb ├── persistence_spec.rb ├── redis_interface_spec.rb ├── spec_helper.rb ├── split_spec.rb ├── support │ └── cookies_mock.rb ├── trial_spec.rb └── user_spec.rb └── split.gemspec /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | csslint: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | - javascript 11 | - python 12 | - php 13 | eslint: 14 | enabled: true 15 | fixme: 16 | enabled: true 17 | rubocop: 18 | enabled: true 19 | ratings: 20 | paths: 21 | - "**.css" 22 | - "**.inc" 23 | - "**.js" 24 | - "**.jsx" 25 | - "**.module" 26 | - "**.php" 27 | - "**.py" 28 | - "**.rb" 29 | exclude_paths: 30 | - spec/ 31 | -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | --exclude-exts=.min.css 2 | --ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | ecmaFeatures: 2 | modules: true 3 | jsx: true 4 | 5 | env: 6 | amd: true 7 | browser: true 8 | es6: true 9 | jquery: true 10 | node: true 11 | 12 | # https://eslint.org/docs/rules/ 13 | rules: 14 | # Possible Errors 15 | comma-dangle: [2, never] 16 | no-cond-assign: 2 17 | no-console: 0 18 | no-constant-condition: 2 19 | no-control-regex: 2 20 | no-debugger: 2 21 | no-dupe-args: 2 22 | no-dupe-keys: 2 23 | no-duplicate-case: 2 24 | no-empty: 2 25 | no-empty-character-class: 2 26 | no-ex-assign: 2 27 | no-extra-boolean-cast: 2 28 | no-extra-parens: 0 29 | no-extra-semi: 2 30 | no-func-assign: 2 31 | no-inner-declarations: [2, functions] 32 | no-invalid-regexp: 2 33 | no-irregular-whitespace: 2 34 | no-negated-in-lhs: 2 35 | no-obj-calls: 2 36 | no-regex-spaces: 2 37 | no-sparse-arrays: 2 38 | no-unexpected-multiline: 2 39 | no-unreachable: 2 40 | use-isnan: 2 41 | valid-jsdoc: 0 42 | valid-typeof: 2 43 | 44 | # Best Practices 45 | accessor-pairs: 2 46 | block-scoped-var: 0 47 | complexity: [2, 6] 48 | consistent-return: 0 49 | curly: 0 50 | default-case: 0 51 | dot-location: 0 52 | dot-notation: 0 53 | eqeqeq: 2 54 | guard-for-in: 2 55 | no-alert: 2 56 | no-caller: 2 57 | no-case-declarations: 2 58 | no-div-regex: 2 59 | no-else-return: 0 60 | no-empty-label: 2 61 | no-empty-pattern: 2 62 | no-eq-null: 2 63 | no-eval: 2 64 | no-extend-native: 2 65 | no-extra-bind: 2 66 | no-fallthrough: 2 67 | no-floating-decimal: 0 68 | no-implicit-coercion: 0 69 | no-implied-eval: 2 70 | no-invalid-this: 0 71 | no-iterator: 2 72 | no-labels: 0 73 | no-lone-blocks: 2 74 | no-loop-func: 2 75 | no-magic-number: 0 76 | no-multi-spaces: 0 77 | no-multi-str: 0 78 | no-native-reassign: 2 79 | no-new-func: 2 80 | no-new-wrappers: 2 81 | no-new: 2 82 | no-octal-escape: 2 83 | no-octal: 2 84 | no-proto: 2 85 | no-redeclare: 2 86 | no-return-assign: 2 87 | no-script-url: 2 88 | no-self-compare: 2 89 | no-sequences: 0 90 | no-throw-literal: 0 91 | no-unused-expressions: 2 92 | no-useless-call: 2 93 | no-useless-concat: 2 94 | no-void: 2 95 | no-warning-comments: 0 96 | no-with: 2 97 | radix: 2 98 | vars-on-top: 0 99 | wrap-iife: 2 100 | yoda: 0 101 | 102 | # Strict 103 | strict: 0 104 | 105 | # Variables 106 | init-declarations: 0 107 | no-catch-shadow: 2 108 | no-delete-var: 2 109 | no-label-var: 2 110 | no-shadow-restricted-names: 2 111 | no-shadow: 0 112 | no-undef-init: 2 113 | no-undef: 0 114 | no-undefined: 0 115 | no-unused-vars: 0 116 | no-use-before-define: 0 117 | 118 | # Node.js and CommonJS 119 | callback-return: 2 120 | global-require: 2 121 | handle-callback-err: 2 122 | no-mixed-requires: 0 123 | no-new-require: 0 124 | no-path-concat: 2 125 | no-process-exit: 2 126 | no-restricted-modules: 0 127 | no-sync: 0 128 | 129 | # Stylistic Issues 130 | array-bracket-spacing: 0 131 | block-spacing: 0 132 | brace-style: 0 133 | camelcase: 0 134 | comma-spacing: 0 135 | comma-style: 0 136 | computed-property-spacing: 0 137 | consistent-this: 0 138 | eol-last: 0 139 | func-names: 0 140 | func-style: 0 141 | id-length: 0 142 | id-match: 0 143 | indent: 0 144 | jsx-quotes: 0 145 | key-spacing: 0 146 | linebreak-style: 0 147 | lines-around-comment: 0 148 | max-depth: 0 149 | max-len: 0 150 | max-nested-callbacks: 0 151 | max-params: 0 152 | max-statements: [2, 30] 153 | new-cap: 0 154 | new-parens: 0 155 | newline-after-var: 0 156 | no-array-constructor: 0 157 | no-bitwise: 0 158 | no-continue: 0 159 | no-inline-comments: 0 160 | no-lonely-if: 0 161 | no-mixed-spaces-and-tabs: 0 162 | no-multiple-empty-lines: 0 163 | no-negated-condition: 0 164 | no-nested-ternary: 0 165 | no-new-object: 0 166 | no-plusplus: 0 167 | no-restricted-syntax: 0 168 | no-spaced-func: 0 169 | no-ternary: 0 170 | no-trailing-spaces: 0 171 | no-underscore-dangle: 0 172 | no-unneeded-ternary: 0 173 | object-curly-spacing: 0 174 | one-var: 0 175 | operator-assignment: 0 176 | operator-linebreak: 0 177 | padded-blocks: 0 178 | quote-props: 0 179 | quotes: 0 180 | require-jsdoc: 0 181 | semi-spacing: 0 182 | semi: 0 183 | sort-vars: 0 184 | space-after-keywords: 0 185 | space-before-blocks: 0 186 | space-before-function-paren: 0 187 | space-before-keywords: 0 188 | space-in-parens: 0 189 | space-infix-ops: 0 190 | space-return-throw-case: 0 191 | space-unary-ops: 0 192 | spaced-comment: 0 193 | wrap-regex: 0 194 | 195 | # ECMAScript 6 196 | arrow-body-style: 0 197 | arrow-parens: 0 198 | arrow-spacing: 0 199 | constructor-super: 0 200 | generator-star-spacing: 0 201 | no-arrow-condition: 0 202 | no-class-assign: 0 203 | no-const-assign: 0 204 | no-dupe-class-members: 0 205 | no-this-before-super: 0 206 | no-var: 0 207 | object-shorthand: 0 208 | prefer-arrow-callback: 0 209 | prefer-const: 0 210 | prefer-reflect: 0 211 | prefer-spread: 0 212 | prefer-template: 0 213 | require-yield: 0 214 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: split 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: andrehjr 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: split 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | ruby: ["3.4", "3.3", "3.2", "3.1", "3.0", "2.7"] 10 | rails: ["8.0", "7.2", "7.1", "6.1"] 11 | exclude: 12 | - rails: "6.1" 13 | ruby: "3.4" 14 | 15 | - rails: "7.2" 16 | ruby: "2.7" 17 | - rails: "7.2" 18 | ruby: "3.0" 19 | 20 | - rails: "8.0" 21 | ruby: "2.7" 22 | - rails: "8.0" 23 | ruby: "3.0" 24 | - rails: "8.0" 25 | ruby: "3.1" 26 | runs-on: ubuntu-latest 27 | 28 | services: 29 | redis: 30 | image: redis 31 | ports: ["6379:6379"] 32 | options: >- 33 | --health-cmd "redis-cli ping" 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | env: 39 | RAILS_VERSION: ${{ matrix.rails }} 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{ matrix.ruby }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | bundle install --jobs 4 --retry 3 50 | 51 | - name: Display Ruby version 52 | run: ruby -v 53 | 54 | - name: Test 55 | run: bundle exec rspec 56 | env: 57 | REDIS_URL: redis:6379 58 | 59 | - name: Rubocop 60 | run: bundle exec rubocop 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | *.rbc 6 | .idea 7 | coverage 8 | issues.rtf 9 | dump.rdb 10 | .gitignore 11 | gemfiles/*.gemfile.lock 12 | .DS_Store -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --profile -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.5 3 | DisabledByDefault: true 4 | SuggestExtensions: false 5 | Exclude: 6 | - 'gemfiles/**/*' 7 | 8 | Style/AndOr: 9 | Enabled: true 10 | 11 | Layout/CaseIndentation: 12 | Enabled: true 13 | 14 | Layout/ClosingHeredocIndentation: 15 | Enabled: true 16 | 17 | Layout/CommentIndentation: 18 | Enabled: true 19 | 20 | Layout/ElseAlignment: 21 | Enabled: true 22 | 23 | Layout/EndAlignment: 24 | Enabled: true 25 | EnforcedStyleAlignWith: variable 26 | AutoCorrect: true 27 | 28 | Layout/EmptyLineAfterMagicComment: 29 | Enabled: true 30 | 31 | Layout/EmptyLinesAroundAccessModifier: 32 | Enabled: true 33 | EnforcedStyle: only_before 34 | 35 | Layout/EmptyLinesAroundBlockBody: 36 | Enabled: true 37 | 38 | Layout/EmptyLinesAroundClassBody: 39 | Enabled: true 40 | 41 | Layout/EmptyLinesAroundMethodBody: 42 | Enabled: true 43 | 44 | Layout/EmptyLinesAroundModuleBody: 45 | Enabled: true 46 | 47 | Style/HashSyntax: 48 | Enabled: true 49 | 50 | Layout/FirstArgumentIndentation: 51 | Enabled: true 52 | 53 | Layout/IndentationConsistency: 54 | Enabled: true 55 | EnforcedStyle: indented_internal_methods 56 | 57 | Layout/IndentationWidth: 58 | Enabled: true 59 | 60 | Layout/LeadingCommentSpace: 61 | Enabled: true 62 | 63 | Layout/SpaceAfterColon: 64 | Enabled: true 65 | 66 | Layout/SpaceAfterComma: 67 | Enabled: true 68 | 69 | Layout/SpaceAfterSemicolon: 70 | Enabled: true 71 | 72 | Layout/SpaceAroundEqualsInParameterDefault: 73 | Enabled: true 74 | 75 | Layout/SpaceAroundKeyword: 76 | Enabled: true 77 | 78 | Layout/SpaceBeforeComma: 79 | Enabled: true 80 | 81 | Layout/SpaceBeforeComment: 82 | Enabled: true 83 | 84 | Layout/SpaceBeforeFirstArg: 85 | Enabled: true 86 | 87 | Style/DefWithParentheses: 88 | Enabled: true 89 | 90 | Style/MethodDefParentheses: 91 | Enabled: true 92 | 93 | Style/FrozenStringLiteralComment: 94 | Enabled: true 95 | EnforcedStyle: always 96 | 97 | Style/RedundantFreeze: 98 | Enabled: true 99 | 100 | Layout/SpaceBeforeBlockBraces: 101 | Enabled: true 102 | 103 | Layout/SpaceInsideBlockBraces: 104 | Enabled: true 105 | EnforcedStyleForEmptyBraces: space 106 | 107 | Layout/SpaceInsideHashLiteralBraces: 108 | Enabled: true 109 | 110 | Layout/SpaceInsideParens: 111 | Enabled: true 112 | 113 | Style/StringLiterals: 114 | Enabled: true 115 | EnforcedStyle: double_quotes 116 | 117 | Layout/IndentationStyle: 118 | Enabled: true 119 | 120 | Layout/TrailingEmptyLines: 121 | Enabled: true 122 | 123 | Layout/TrailingWhitespace: 124 | Enabled: true 125 | 126 | Style/RedundantPercentQ: 127 | Enabled: true 128 | 129 | Lint/AmbiguousOperator: 130 | Enabled: true 131 | 132 | Lint/AmbiguousRegexpLiteral: 133 | Enabled: true 134 | 135 | Lint/ErbNewArguments: 136 | Enabled: true 137 | 138 | Lint/RequireParentheses: 139 | Enabled: true 140 | 141 | Lint/ShadowingOuterLocalVariable: 142 | Enabled: true 143 | 144 | Lint/RedundantStringCoercion: 145 | Enabled: true 146 | 147 | Lint/UriEscapeUnescape: 148 | Enabled: true 149 | 150 | Lint/UselessAssignment: 151 | Enabled: true 152 | 153 | Lint/DeprecatedClassMethods: 154 | Enabled: true 155 | 156 | Style/ParenthesesAroundCondition: 157 | Enabled: true 158 | 159 | Style/HashTransformKeys: 160 | Enabled: true 161 | 162 | Style/HashTransformValues: 163 | Enabled: true 164 | 165 | Style/RedundantBegin: 166 | Enabled: true 167 | 168 | Style/RedundantReturn: 169 | Enabled: true 170 | AllowMultipleReturnValues: true 171 | 172 | Style/Semicolon: 173 | Enabled: true 174 | AllowAsExpressionSeparator: true 175 | 176 | Style/ColonMethodCall: 177 | Enabled: true 178 | 179 | Style/TrivialAccessors: 180 | Enabled: true 181 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at andrewnez@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Split 2 | 3 | Want to contribute to Split? That's great! Here are a couple of guidelines that will help you contribute. Before we get started: Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md) to ensure that this project is a welcoming place for **everyone** to contribute to. By participating in this project you agree to abide by its terms. 4 | 5 | #### Overview 6 | 7 | * [Contribution workflow](#contribution-workflow) 8 | * [Setup instructions](#setup-instructions) 9 | * [Reporting a bug](#reporting-a-bug) 10 | * [Contributing to an existing issue](#contributing-to-an-existing-issue) 11 | * [Our labels](#our-labels) 12 | * [Additional info](#additional-info) 13 | 14 | ## Contribution workflow 15 | 16 | * Fork the project. 17 | * Make your feature addition or bug fix. 18 | * Add tests for it. This is important so I don't break it in a 19 | future version unintentionally. 20 | * Add documentation if necessary. 21 | * Commit. Do not mess with the Rakefile, version, or history. 22 | (If you want to have your own version, that is fine. But bump version in a commit by itself I can ignore when I pull.) 23 | * Send a pull request. Bonus points for topic branches. 24 | * Discussion at the [Google Group](https://groups.google.com/d/forum/split-ruby) 25 | 26 | ## Setup instructions 27 | 28 | You can find in-depth instructions to install in our [README](https://github.com/splitrb/split/blob/main/README.md). 29 | 30 | *Note*: Split requires Ruby 1.9.2 or higher. 31 | 32 | ## Reporting a bug 33 | 34 | So you've found a bug, and want to help us fix it? Before filing a bug report, please double-check the bug hasn't already been reported. You can do so [on our issue tracker](https://github.com/splitrb/split/issues?q=is%3Aissue+is%3Aopen+label%3Abug). If something hasn't been raised, you can go ahead and create a new issue with the following information: 35 | 36 | * When did the error happen? 37 | * How can the error be reproduced? 38 | * If possible, please also provide an error message or a screenshot to illustrate the problem. 39 | 40 | If you want to be really thorough, there is a great overview on Stack Overflow of [what you should consider when reporting a bug](http://stackoverflow.com/questions/240323/how-to-report-bugs-the-smart-way). 41 | 42 | It goes without saying that you're welcome to help investigate further and/or find a fix for the bug. If you want to do so, just mention it in your bug report and offer your help! 43 | 44 | ## Contributing to an existing issue 45 | 46 | ### Finding an issue to work on 47 | 48 | We've got a few open issues and are always glad to get help on that front. You can view the list of issues [here](https://github.com/splitrb/split/issues). Most of the issues are labelled, so you can use the labels to get an idea of which issue could be a good fit for you. (Here's [a good article](https://medium.freecodecamp.com/finding-your-first-open-source-project-or-bug-to-work-on-1712f651e5ba) on how to find your first bug to fix). 49 | 50 | Before getting to work, take a look at the issue and at the conversation around it. Has someone already offered to work on the issue? Has someone been assigned to the issue? If so, you might want to check with them to see whether they're still actively working on it. 51 | 52 | If the issue is a few months old, it might be a good idea to write a short comment to double-check that the issue or feature is still a valid one to jump on. 53 | 54 | Feel free to ask for more detail on what is expected: are there any more details or specifications you need to know? 55 | 56 | And if at any point you get stuck: don't hesitate to ask for help. 57 | 58 | ### Making your contribution 59 | 60 | We've outlined the contribution workflow [here](#contribution-workflow). If you're a first-timer, don't worry! GitHub has a ton of guides to help you through your first pull request: You can find out more about pull requests [here](https://help.github.com/articles/about-pull-requests/) and about creating a pull request [here](https://help.github.com/articles/creating-a-pull-request/). 61 | 62 | Especially if you're a newcomer to Open Source and you've found some little bumps along the way while contributing, we recommend you write about them. [Here](https://medium.freecodecamp.com/new-contributors-to-open-source-please-blog-more-920af14cffd)'s a great article about why writing about your experience is important; this will encourage other beginners to try their luck at Open Source, too! 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rubocop", require: false 8 | gem "codeclimate-test-reporter" 9 | gem "concurrent-ruby", "< 1.3.5" 10 | 11 | gem "rails", "~> #{ENV.fetch('RAILS_VERSION', '8.0')}" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Andrew Nesbitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require "bundler/gem_tasks" 5 | require "rspec/core/rake_task" 6 | 7 | RSpec::Core::RakeTask.new("spec") 8 | 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /lib/split.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis" 4 | 5 | require "split/algorithms" 6 | require "split/algorithms/block_randomization" 7 | require "split/algorithms/weighted_sample" 8 | require "split/algorithms/whiplash" 9 | require "split/alternative" 10 | require "split/cache" 11 | require "split/configuration" 12 | require "split/encapsulated_helper" 13 | require "split/exceptions" 14 | require "split/experiment" 15 | require "split/experiment_catalog" 16 | require "split/extensions/string" 17 | require "split/goals_collection" 18 | require "split/helper" 19 | require "split/combined_experiments_helper" 20 | require "split/metric" 21 | require "split/persistence" 22 | require "split/redis_interface" 23 | require "split/trial" 24 | require "split/user" 25 | require "split/version" 26 | require "split/zscore" 27 | require "split/engine" if defined?(::Rails::Engine) 28 | 29 | module Split 30 | extend self 31 | attr_accessor :configuration 32 | 33 | # Accepts: 34 | # 1. A redis URL (valid for `Redis.new(url: url)`) 35 | # 2. an options hash compatible with `Redis.new` 36 | # 3. or a valid Redis instance (one that responds to `#smembers`). Likely, 37 | # this will be an instance of either `Redis`, `Redis::Client`, 38 | # `Redis::DistRedis`, or `Redis::Namespace`. 39 | def redis=(server) 40 | @redis = if server.is_a?(String) 41 | Redis.new(url: server) 42 | elsif server.is_a?(Hash) 43 | Redis.new(server) 44 | elsif server.respond_to?(:smembers) 45 | server 46 | else 47 | raise ArgumentError, 48 | "You must supply a url, options hash or valid Redis connection instance" 49 | end 50 | end 51 | 52 | # Returns the current Redis connection. If none has been created, will 53 | # create a new one. 54 | def redis 55 | return @redis if @redis 56 | self.redis = self.configuration.redis 57 | self.redis 58 | end 59 | 60 | # Call this method to modify defaults in your initializers. 61 | # 62 | # @example 63 | # Split.configure do |config| 64 | # config.ignore_ip_addresses = '192.168.2.1' 65 | # end 66 | def configure 67 | self.configuration ||= Configuration.new 68 | yield(configuration) 69 | end 70 | 71 | def cache(namespace, key, &block) 72 | Split::Cache.fetch(namespace, key, &block) 73 | end 74 | end 75 | 76 | # Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first. 77 | if defined?(::Rails::Railtie) 78 | class Split::Railtie < Rails::Railtie 79 | config.before_initialize { Split.configure { } } 80 | end 81 | else 82 | Split.configure { } 83 | end 84 | -------------------------------------------------------------------------------- /lib/split/algorithms.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "matrix" 4 | require "rubystats" 5 | 6 | module Split 7 | module Algorithms 8 | class << self 9 | def beta_distribution_rng(a, b) 10 | Rubystats::BetaDistribution.new(a, b).rng 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/split/algorithms/block_randomization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Selects alternative with minimum count of participants 4 | # If all counts are even (i.e. all are minimum), samples from all possible alternatives 5 | 6 | module Split 7 | module Algorithms 8 | module BlockRandomization 9 | class << self 10 | def choose_alternative(experiment) 11 | minimum_participant_alternatives(experiment.alternatives).sample 12 | end 13 | 14 | private 15 | def minimum_participant_alternatives(alternatives) 16 | alternatives_by_count = alternatives.group_by(&:participant_count) 17 | min_group = alternatives_by_count.min_by { |k, v| k } 18 | min_group.last 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/split/algorithms/weighted_sample.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Algorithms 5 | module WeightedSample 6 | def self.choose_alternative(experiment) 7 | weights = experiment.alternatives.map(&:weight) 8 | 9 | total = weights.inject(:+) 10 | point = rand * total 11 | 12 | experiment.alternatives.zip(weights).each do |n, w| 13 | return n if w >= point 14 | point -= w 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/split/algorithms/whiplash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A multi-armed bandit implementation inspired by 4 | # @aaronsw and victorykit/whiplash 5 | 6 | module Split 7 | module Algorithms 8 | module Whiplash 9 | class << self 10 | def choose_alternative(experiment) 11 | experiment[best_guess(experiment.alternatives)] 12 | end 13 | 14 | private 15 | def arm_guess(participants, completions) 16 | a = [participants, 0].max 17 | b = [participants-completions, 0].max 18 | Split::Algorithms.beta_distribution_rng(a + fairness_constant, b + fairness_constant) 19 | end 20 | 21 | def best_guess(alternatives) 22 | guesses = {} 23 | alternatives.each do |alternative| 24 | guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.all_completed_count) 25 | end 26 | gmax = guesses.values.max 27 | best = guesses.keys.select { |name| guesses[name] == gmax } 28 | best.sample 29 | end 30 | 31 | def fairness_constant 32 | 7 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/split/alternative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Alternative 5 | attr_accessor :name 6 | attr_accessor :experiment_name 7 | attr_accessor :weight 8 | attr_accessor :recorded_info 9 | 10 | def initialize(name, experiment_name) 11 | @experiment_name = experiment_name 12 | if Hash === name 13 | @name = name.keys.first 14 | @weight = name.values.first 15 | else 16 | @name = name 17 | @weight = 1 18 | end 19 | @p_winner = 0.0 20 | end 21 | 22 | def to_s 23 | name 24 | end 25 | 26 | def goals 27 | self.experiment.goals 28 | end 29 | 30 | def p_winner(goal = nil) 31 | field = set_prob_field(goal) 32 | @p_winner = Split.redis.hget(key, field).to_f 33 | end 34 | 35 | def set_p_winner(prob, goal = nil) 36 | field = set_prob_field(goal) 37 | Split.redis.hset(key, field, prob.to_f) 38 | end 39 | 40 | def participant_count 41 | Split.redis.hget(key, "participant_count").to_i 42 | end 43 | 44 | def participant_count=(count) 45 | Split.redis.hset(key, "participant_count", count.to_i) 46 | end 47 | 48 | def completed_count(goal = nil) 49 | field = set_field(goal) 50 | Split.redis.hget(key, field).to_i 51 | end 52 | 53 | def all_completed_count 54 | if goals.empty? 55 | completed_count 56 | else 57 | goals.inject(completed_count) do |sum, g| 58 | sum + completed_count(g) 59 | end 60 | end 61 | end 62 | 63 | def unfinished_count 64 | participant_count - all_completed_count 65 | end 66 | 67 | def set_field(goal) 68 | field = "completed_count" 69 | field += ":" + goal unless goal.nil? 70 | field 71 | end 72 | 73 | def set_prob_field(goal) 74 | field = "p_winner" 75 | field += ":" + goal unless goal.nil? 76 | field 77 | end 78 | 79 | def set_completed_count(count, goal = nil) 80 | field = set_field(goal) 81 | Split.redis.hset(key, field, count.to_i) 82 | end 83 | 84 | def increment_participation 85 | Split.redis.hincrby key, "participant_count", 1 86 | end 87 | 88 | def increment_completion(goal = nil) 89 | field = set_field(goal) 90 | Split.redis.hincrby(key, field, 1) 91 | end 92 | 93 | def control? 94 | experiment.control.name == self.name 95 | end 96 | 97 | def conversion_rate(goal = nil) 98 | return 0 if participant_count.zero? 99 | (completed_count(goal).to_f)/participant_count.to_f 100 | end 101 | 102 | def experiment 103 | Split::ExperimentCatalog.find(experiment_name) 104 | end 105 | 106 | def z_score(goal = nil) 107 | # p_a = Pa = proportion of users who converted within the experiment split (conversion rate) 108 | # p_c = Pc = proportion of users who converted within the control split (conversion rate) 109 | # n_a = Na = the number of impressions within the experiment split 110 | # n_c = Nc = the number of impressions within the control split 111 | 112 | control = experiment.control 113 | alternative = self 114 | 115 | return "N/A" if control.name == alternative.name 116 | 117 | p_a = alternative.conversion_rate(goal) 118 | p_c = control.conversion_rate(goal) 119 | 120 | n_a = alternative.participant_count 121 | n_c = control.participant_count 122 | 123 | # can't calculate zscore for P(x) > 1 124 | return "N/A" if p_a > 1 || p_c > 1 125 | 126 | Split::Zscore.calculate(p_a, n_a, p_c, n_c) 127 | end 128 | 129 | def extra_info 130 | data = Split.redis.hget(key, "recorded_info") 131 | if data && data.length > 1 132 | begin 133 | JSON.parse(data) 134 | rescue 135 | {} 136 | end 137 | else 138 | {} 139 | end 140 | end 141 | 142 | def record_extra_info(k, value = 1) 143 | @recorded_info = self.extra_info || {} 144 | 145 | if value.kind_of?(Numeric) 146 | @recorded_info[k] ||= 0 147 | @recorded_info[k] += value 148 | else 149 | @recorded_info[k] = value 150 | end 151 | 152 | Split.redis.hset key, "recorded_info", (@recorded_info || {}).to_json 153 | end 154 | 155 | def save 156 | Split.redis.hsetnx key, "participant_count", 0 157 | Split.redis.hsetnx key, "completed_count", 0 158 | Split.redis.hsetnx key, "p_winner", p_winner 159 | Split.redis.hsetnx key, "recorded_info", (@recorded_info || {}).to_json 160 | end 161 | 162 | def validate! 163 | unless String === @name || hash_with_correct_values?(@name) 164 | raise ArgumentError, "Alternative must be a string" 165 | end 166 | end 167 | 168 | def reset 169 | Split.redis.hmset key, "participant_count", 0, "completed_count", 0, "recorded_info", "" 170 | unless goals.empty? 171 | goals.each do |g| 172 | field = "completed_count:#{g}" 173 | Split.redis.hset key, field, 0 174 | end 175 | end 176 | end 177 | 178 | def delete 179 | Split.redis.del(key) 180 | end 181 | 182 | private 183 | def hash_with_correct_values?(name) 184 | Hash === name && String === name.keys.first && Float(name.values.first) rescue false 185 | end 186 | 187 | def key 188 | "#{experiment_name}:#{name}" 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/split/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Cache 5 | def self.clear 6 | @cache = nil 7 | end 8 | 9 | def self.fetch(namespace, key) 10 | return yield unless Split.configuration.cache 11 | 12 | @cache ||= {} 13 | @cache[namespace] ||= {} 14 | 15 | value = @cache[namespace][key] 16 | return value if value 17 | 18 | @cache[namespace][key] = yield 19 | end 20 | 21 | def self.clear_key(key) 22 | @cache&.keys&.each do |namespace| 23 | @cache[namespace]&.delete(key) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/split/combined_experiments_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module CombinedExperimentsHelper 5 | def ab_combined_test(metric_descriptor, control = nil, *alternatives) 6 | return nil unless experiment = find_combined_experiment(metric_descriptor) 7 | raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil? 8 | 9 | alternative = nil 10 | weighted_alternatives = nil 11 | experiment[:combined_experiments].each do |combined_experiment| 12 | if alternative.nil? 13 | if control 14 | alternative = ab_test(combined_experiment, control, alternatives) 15 | else 16 | normalized_alternatives = Split::Configuration.new.normalize_alternatives(experiment[:alternatives]) 17 | alternative = ab_test(combined_experiment, normalized_alternatives[0], *normalized_alternatives[1]) 18 | end 19 | else 20 | weighted_alternatives ||= experiment[:alternatives].each_with_object({}) do |alt, memo| 21 | alt = Alternative.new(alt, experiment[:name]).name 22 | memo[alt] = (alt == alternative ? 1 : 0) 23 | end 24 | 25 | ab_test(combined_experiment, [weighted_alternatives]) 26 | end 27 | end 28 | alternative 29 | end 30 | 31 | def find_combined_experiment(metric_descriptor) 32 | raise(Split::InvalidExperimentsFormatError, "Invalid descriptor class (String or Symbol required)") unless metric_descriptor.class == String || metric_descriptor.class == Symbol 33 | raise(Split::InvalidExperimentsFormatError, "Enable configuration") unless Split.configuration.enabled 34 | raise(Split::InvalidExperimentsFormatError, "Enable `allow_multiple_experiments`") unless Split.configuration.allow_multiple_experiments 35 | Split.configuration.experiments[metric_descriptor.to_sym] 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/split/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Configuration 5 | attr_accessor :ignore_ip_addresses 6 | attr_accessor :ignore_filter 7 | attr_accessor :db_failover 8 | attr_accessor :db_failover_on_db_error 9 | attr_accessor :db_failover_allow_parameter_override 10 | attr_accessor :allow_multiple_experiments 11 | attr_accessor :enabled 12 | attr_accessor :persistence 13 | attr_accessor :persistence_cookie_length 14 | attr_accessor :persistence_cookie_domain 15 | attr_accessor :algorithm 16 | attr_accessor :store_override 17 | attr_accessor :start_manually 18 | attr_accessor :reset_manually 19 | attr_accessor :on_trial 20 | attr_accessor :on_trial_choose 21 | attr_accessor :on_trial_complete 22 | attr_accessor :on_experiment_reset 23 | attr_accessor :on_experiment_delete 24 | attr_accessor :on_before_experiment_reset 25 | attr_accessor :on_experiment_winner_choose 26 | attr_accessor :on_before_experiment_delete 27 | attr_accessor :include_rails_helper 28 | attr_accessor :beta_probability_simulations 29 | attr_accessor :winning_alternative_recalculation_interval 30 | attr_accessor :redis 31 | attr_accessor :dashboard_pagination_default_per_page 32 | attr_accessor :cache 33 | 34 | attr_reader :experiments 35 | 36 | attr_writer :bots 37 | attr_writer :robot_regex 38 | 39 | def bots 40 | @bots ||= { 41 | # Indexers 42 | "AdsBot-Google" => "Google Adwords", 43 | "Baidu" => "Chinese search engine", 44 | "Baiduspider" => "Chinese search engine", 45 | "bingbot" => "Microsoft bing bot", 46 | "Butterfly" => "Topsy Labs", 47 | "Gigabot" => "Gigabot spider", 48 | "Googlebot" => "Google spider", 49 | "MJ12bot" => "Majestic-12 spider", 50 | "msnbot" => "Microsoft bot", 51 | "rogerbot" => "SeoMoz spider", 52 | "PaperLiBot" => "PaperLi is another content curation service", 53 | "Slurp" => "Yahoo spider", 54 | "Sogou" => "Chinese search engine", 55 | "spider" => "generic web spider", 56 | "UnwindFetchor" => "Gnip crawler", 57 | "WordPress" => "WordPress spider", 58 | "YandexAccessibilityBot" => "Yandex accessibility spider", 59 | "YandexBot" => "Yandex spider", 60 | "YandexMobileBot" => "Yandex mobile spider", 61 | "ZIBB" => "ZIBB spider", 62 | 63 | # HTTP libraries 64 | "Apache-HttpClient" => "Java http library", 65 | "AppEngine-Google" => "Google App Engine", 66 | "curl" => "curl unix CLI http client", 67 | "ColdFusion" => "ColdFusion http library", 68 | "EventMachine HttpClient" => "Ruby http library", 69 | "Go http package" => "Go http library", 70 | "Go-http-client" => "Go http library", 71 | "Java" => "Generic Java http library", 72 | "libwww-perl" => "Perl client-server library loved by script kids", 73 | "lwp-trivial" => "Another Perl library loved by script kids", 74 | "Python-urllib" => "Python http library", 75 | "PycURL" => "Python http library", 76 | "Test Certificate Info" => "C http library?", 77 | "Typhoeus" => "Ruby http library", 78 | "Wget" => "wget unix CLI http client", 79 | 80 | # URL expanders / previewers 81 | "awe.sm" => "Awe.sm URL expander", 82 | "bitlybot" => "bit.ly bot", 83 | "bot@linkfluence.net" => "Linkfluence bot", 84 | "facebookexternalhit" => "facebook bot", 85 | "Facebot" => "Facebook crawler", 86 | "Feedfetcher-Google" => "Google Feedfetcher", 87 | "https://developers.google.com/+/web/snippet" => "Google+ Snippet Fetcher", 88 | "LinkedInBot" => "LinkedIn bot", 89 | "LongURL" => "URL expander service", 90 | "NING" => "NING - Yet Another Twitter Swarmer", 91 | "Pinterestbot" => "Pinterest Bot", 92 | "redditbot" => "Reddit Bot", 93 | "ShortLinkTranslate" => "Link shortener", 94 | "Slackbot" => "Slackbot link expander", 95 | "TweetmemeBot" => "TweetMeMe Crawler", 96 | "Twitterbot" => "Twitter URL expander", 97 | "UnwindFetch" => "Gnip URL expander", 98 | "vkShare" => "VKontake Sharer", 99 | 100 | # Uptime monitoring 101 | "check_http" => "Nagios monitor", 102 | "GoogleStackdriverMonitoring" => "Google Cloud monitor", 103 | "NewRelicPinger" => "NewRelic monitor", 104 | "Panopta" => "Monitoring service", 105 | "Pingdom" => "Pingdom monitoring", 106 | "SiteUptime" => "Site monitoring services", 107 | "UptimeRobot" => "Monitoring service", 108 | 109 | # ??? 110 | "DigitalPersona Fingerprint Software" => "HP Fingerprint scanner", 111 | "ShowyouBot" => "Showyou iOS app spider", 112 | "ZyBorg" => "Zyborg? Hmmm....", 113 | "ELB-HealthChecker" => "ELB Health Check" 114 | } 115 | end 116 | 117 | def experiments=(experiments) 118 | raise InvalidExperimentsFormatError.new("Experiments must be a Hash") unless experiments.respond_to?(:keys) 119 | @experiments = experiments 120 | end 121 | 122 | def disabled? 123 | !enabled 124 | end 125 | 126 | def experiment_for(name) 127 | if normalized_experiments 128 | # TODO symbols 129 | normalized_experiments[name.to_sym] 130 | end 131 | end 132 | 133 | def metrics 134 | return @metrics if defined?(@metrics) 135 | @metrics = {} 136 | if self.experiments 137 | self.experiments.each do |key, value| 138 | metrics = value_for(value, :metric) rescue nil 139 | Array(metrics).each do |metric_name| 140 | if metric_name 141 | @metrics[metric_name.to_sym] ||= [] 142 | @metrics[metric_name.to_sym] << Split::Experiment.new(key) 143 | end 144 | end 145 | end 146 | end 147 | @metrics 148 | end 149 | 150 | def normalized_experiments 151 | return nil if @experiments.nil? 152 | 153 | experiment_config = {} 154 | @experiments.keys.each do |name| 155 | experiment_config[name.to_sym] = {} 156 | end 157 | 158 | @experiments.each do |experiment_name, settings| 159 | alternatives = if (alts = value_for(settings, :alternatives)) 160 | normalize_alternatives(alts) 161 | end 162 | 163 | experiment_data = { 164 | alternatives: alternatives, 165 | goals: value_for(settings, :goals), 166 | metadata: value_for(settings, :metadata), 167 | algorithm: value_for(settings, :algorithm), 168 | resettable: value_for(settings, :resettable) 169 | } 170 | 171 | experiment_data.each do |name, value| 172 | experiment_config[experiment_name.to_sym][name] = value if value != nil 173 | end 174 | end 175 | 176 | experiment_config 177 | end 178 | 179 | def normalize_alternatives(alternatives) 180 | given_probability, num_with_probability = alternatives.inject([0, 0]) do |a, v| 181 | p, n = a 182 | if percent = value_for(v, :percent) 183 | [p + percent, n + 1] 184 | else 185 | a 186 | end 187 | end 188 | 189 | num_without_probability = alternatives.length - num_with_probability 190 | unassigned_probability = ((100.0 - given_probability) / num_without_probability / 100.0) 191 | 192 | if num_with_probability.nonzero? 193 | alternatives = alternatives.map do |v| 194 | if (name = value_for(v, :name)) && (percent = value_for(v, :percent)) 195 | { name => percent / 100.0 } 196 | elsif name = value_for(v, :name) 197 | { name => unassigned_probability } 198 | else 199 | { v => unassigned_probability } 200 | end 201 | end 202 | 203 | [alternatives.shift, alternatives] 204 | else 205 | alternatives = alternatives.dup 206 | [alternatives.shift, alternatives] 207 | end 208 | end 209 | 210 | def robot_regex 211 | @robot_regex ||= /\b(?:#{escaped_bots.join('|')})\b|\A\W*\z/i 212 | end 213 | 214 | def initialize 215 | @ignore_ip_addresses = [] 216 | @ignore_filter = proc { |request| is_robot? || is_ignored_ip_address? } 217 | @db_failover = false 218 | @db_failover_on_db_error = proc { |error| } # e.g. use Rails logger here 219 | @on_experiment_reset = proc { |experiment| } 220 | @on_experiment_delete = proc { |experiment| } 221 | @on_before_experiment_reset = proc { |experiment| } 222 | @on_before_experiment_delete = proc { |experiment| } 223 | @on_experiment_winner_choose = proc { |experiment| } 224 | @db_failover_allow_parameter_override = false 225 | @allow_multiple_experiments = false 226 | @enabled = true 227 | @experiments = {} 228 | @persistence = Split::Persistence::SessionAdapter 229 | @persistence_cookie_length = 31536000 # One year from now 230 | @persistence_cookie_domain = nil 231 | @algorithm = Split::Algorithms::WeightedSample 232 | @include_rails_helper = true 233 | @beta_probability_simulations = 10000 234 | @winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day 235 | @redis = ENV.fetch(ENV.fetch("REDIS_PROVIDER", "REDIS_URL"), "redis://localhost:6379") 236 | @dashboard_pagination_default_per_page = 10 237 | end 238 | 239 | private 240 | def value_for(hash, key) 241 | if hash.kind_of?(Hash) 242 | hash.has_key?(key.to_s) ? hash[key.to_s] : hash[key.to_sym] 243 | end 244 | end 245 | 246 | def escaped_bots 247 | bots.map { |key, _| Regexp.escape(key) } 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /lib/split/dashboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sinatra/base" 4 | require "split" 5 | require "bigdecimal" 6 | require "split/dashboard/helpers" 7 | require "split/dashboard/pagination_helpers" 8 | 9 | module Split 10 | class Dashboard < Sinatra::Base 11 | dir = File.dirname(File.expand_path(__FILE__)) 12 | 13 | set :views, "#{dir}/dashboard/views" 14 | set :public_folder, "#{dir}/dashboard/public" 15 | set :static, true 16 | set :method_override, true 17 | 18 | helpers Split::DashboardHelpers 19 | helpers Split::DashboardPaginationHelpers 20 | 21 | get "/" do 22 | # Display experiments without a winner at the top of the dashboard 23 | @experiments = Split::ExperimentCatalog.all_active_first 24 | @unintialized_experiments = Split.configuration.experiments.keys - @experiments.map(&:name) 25 | 26 | @metrics = Split::Metric.all 27 | 28 | # Display Rails Environment mode (or Rack version if not using Rails) 29 | if Object.const_defined?("Rails") && Rails.respond_to?(:env) 30 | @current_env = Rails.env.titlecase 31 | else 32 | rack_version = Rack.respond_to?(:version) ? Rack.version : Rack.release 33 | @current_env = "Rack: #{rack_version}" 34 | end 35 | erb :index 36 | end 37 | 38 | post "/initialize_experiment" do 39 | Split::ExperimentCatalog.find_or_create(params[:experiment]) unless params[:experiment].nil? || params[:experiment].empty? 40 | redirect url("/") 41 | end 42 | 43 | post "/force_alternative" do 44 | experiment = Split::ExperimentCatalog.find(params[:experiment]) 45 | alternative = Split::Alternative.new(params[:alternative], experiment.name) 46 | 47 | cookies = JSON.parse(request.cookies["split_override"]) rescue {} 48 | cookies[experiment.name] = alternative.name 49 | response.set_cookie("split_override", { value: cookies.to_json, path: "/" }) 50 | 51 | redirect url("/") 52 | end 53 | 54 | post "/experiment" do 55 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 56 | @alternative = Split::Alternative.new(params[:alternative], params[:experiment]) 57 | @experiment.winner = @alternative.name 58 | redirect url("/") 59 | end 60 | 61 | post "/start" do 62 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 63 | @experiment.start 64 | redirect url("/") 65 | end 66 | 67 | post "/reset" do 68 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 69 | @experiment.reset 70 | redirect url("/") 71 | end 72 | 73 | post "/reopen" do 74 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 75 | @experiment.reset_winner 76 | redirect url("/") 77 | end 78 | 79 | post "/update_cohorting" do 80 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 81 | case params[:cohorting_action].downcase 82 | when "enable" 83 | @experiment.enable_cohorting 84 | when "disable" 85 | @experiment.disable_cohorting 86 | end 87 | redirect url("/") 88 | end 89 | 90 | delete "/experiment" do 91 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 92 | @experiment.delete 93 | redirect url("/") 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/split/dashboard/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module DashboardHelpers 5 | def h(text) 6 | Rack::Utils.escape_html(text) 7 | end 8 | 9 | def url(*path_parts) 10 | [ path_prefix, path_parts ].join("/").squeeze("/") 11 | end 12 | 13 | def path_prefix 14 | request.env["SCRIPT_NAME"] 15 | end 16 | 17 | def number_to_percentage(number, precision = 2) 18 | round(number * 100) 19 | end 20 | 21 | def round(number, precision = 2) 22 | begin 23 | BigDecimal(number.to_s) 24 | rescue ArgumentError 25 | BigDecimal(0) 26 | end.round(precision).to_f 27 | end 28 | 29 | def confidence_level(z_score) 30 | return z_score if z_score.is_a? String 31 | 32 | z = round(z_score.to_s.to_f, 3).abs 33 | 34 | if z >= 2.58 35 | "99% confidence" 36 | elsif z >= 1.96 37 | "95% confidence" 38 | elsif z >= 1.65 39 | "90% confidence" 40 | else 41 | "Insufficient confidence" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/split/dashboard/pagination_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "split/dashboard/paginator" 4 | 5 | module Split 6 | module DashboardPaginationHelpers 7 | def pagination_per 8 | default_per_page = Split.configuration.dashboard_pagination_default_per_page 9 | @pagination_per ||= (params[:per] || default_per_page).to_i 10 | end 11 | 12 | def page_number 13 | @page_number ||= (params[:page] || 1).to_i 14 | end 15 | 16 | def paginated(collection) 17 | Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate 18 | end 19 | 20 | def pagination(collection) 21 | html = [] 22 | html << first_page_tag if show_first_page_tag? 23 | html << ellipsis_tag if show_first_ellipsis_tag? 24 | html << prev_page_tag if show_prev_page_tag? 25 | html << current_page_tag 26 | html << next_page_tag if show_next_page_tag?(collection) 27 | html << ellipsis_tag if show_last_ellipsis_tag?(collection) 28 | html << last_page_tag(collection) if show_last_page_tag?(collection) 29 | html.join 30 | end 31 | 32 | private 33 | def show_first_page_tag? 34 | page_number > 2 35 | end 36 | 37 | def first_page_tag 38 | %Q(1) 39 | end 40 | 41 | def show_first_ellipsis_tag? 42 | page_number >= 4 43 | end 44 | 45 | def ellipsis_tag 46 | "..." 47 | end 48 | 49 | def show_prev_page_tag? 50 | page_number > 1 51 | end 52 | 53 | def prev_page_tag 54 | %Q(#{page_number - 1}) 55 | end 56 | 57 | def current_page_tag 58 | "#{page_number}" 59 | end 60 | 61 | def show_next_page_tag?(collection) 62 | (page_number * pagination_per) < collection.count 63 | end 64 | 65 | def next_page_tag 66 | %Q(#{page_number + 1}) 67 | end 68 | 69 | def show_last_ellipsis_tag?(collection) 70 | (total_pages(collection) - page_number) >= 3 71 | end 72 | 73 | def total_pages(collection) 74 | collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1) 75 | end 76 | 77 | def show_last_page_tag?(collection) 78 | page_number < (total_pages(collection) - 1) 79 | end 80 | 81 | def last_page_tag(collection) 82 | total = total_pages(collection) 83 | %Q(#{total}) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/split/dashboard/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class DashboardPaginator 5 | def initialize(collection, page_number, per) 6 | @collection = collection 7 | @page_number = page_number 8 | @per = per 9 | end 10 | 11 | def paginate 12 | to = @page_number * @per 13 | from = to - @per 14 | @collection[from...to] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/dashboard-filtering.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('#filter').on('keyup', function() { 3 | $input = $(this); 4 | 5 | if ($input.val() === '') { 6 | $('div.experiment').show(); 7 | return false; 8 | } 9 | 10 | $('div.experiment').hide(); 11 | selector = 'div.experiment[data-name*="' + $input.val() + '"]'; 12 | $(selector).show(); 13 | }); 14 | 15 | $('#clear-filter').on('click', function() { 16 | $('#filter').val(''); 17 | $('div.experiment').show(); 18 | $('#toggle-active').val('Hide active'); 19 | $('#toggle-completed').val('Hide completed'); 20 | }); 21 | 22 | $('#toggle-active').on('click', function() { 23 | $button = $(this); 24 | if ($button.val() === 'Hide active') { 25 | $button.val('Show active'); 26 | } else { 27 | $button.val('Hide active'); 28 | } 29 | 30 | $('div.experiment[data-complete="false"]').toggle(); 31 | }); 32 | 33 | $('#toggle-completed').on('click', function() { 34 | $button = $(this); 35 | if ($button.val() === 'Hide completed') { 36 | $button.val('Show completed'); 37 | } else { 38 | $button.val('Hide completed'); 39 | } 40 | 41 | $('div.experiment[data-complete="true"]').toggle(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/dashboard.js: -------------------------------------------------------------------------------- 1 | function confirmReset() { 2 | var agree = confirm("This will delete all data for this experiment?"); 3 | return agree ? true : false; 4 | } 5 | 6 | function confirmDelete() { 7 | var agree = confirm("Are you sure you want to delete this experiment and all its data?"); 8 | return agree ? true : false; 9 | } 10 | 11 | function confirmWinner() { 12 | var agree = confirm("This will now be returned for all users. Are you sure?"); 13 | return agree ? true : false; 14 | } 15 | 16 | function confirmStep(step) { 17 | var agree = confirm(step); 18 | return agree ? true : false; 19 | } 20 | 21 | function confirmReopen() { 22 | var agree = confirm("This will reopen the experiment. Are you sure?"); 23 | return agree ? true : false; 24 | } 25 | 26 | function confirmEnableCohorting(){ 27 | var agree = confirm("This will enable the cohorting of the experiment. Are you sure?"); 28 | return agree ? true : false; 29 | } 30 | 31 | function confirmDisableCohorting(){ 32 | var agree = confirm("This will disable the cohorting of the experiment. Note: Existing participants will continue to receive their alternative and may continue to convert. Are you sure?"); 33 | return agree ? true : false; 34 | } 35 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, font, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | dl, dt, dd, ul, li, 7 | form, label, legend, 8 | table, caption, tbody, tfoot, thead, tr, th, td { 9 | margin: 0; 10 | padding: 0; 11 | border: 0; 12 | outline: 0; 13 | font-weight: inherit; 14 | font-style: normal; 15 | font-size: 100%; 16 | font-family: inherit; 17 | } 18 | 19 | :focus { 20 | outline: 0; 21 | } 22 | 23 | body { 24 | line-height: 1; 25 | } 26 | 27 | ul { 28 | list-style: none; 29 | } 30 | 31 | table { 32 | border-collapse: collapse; 33 | border-spacing: 0; 34 | } 35 | 36 | caption, th, td { 37 | text-align: left; 38 | font-weight: normal; 39 | } 40 | 41 | blockquote:before, blockquote:after, 42 | q:before, q:after { 43 | content: ""; 44 | } 45 | 46 | blockquote, q { 47 | quotes: "" ""; 48 | } -------------------------------------------------------------------------------- /lib/split/dashboard/public/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: #efefef; 3 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 4 | font-size: 13px; 5 | } 6 | 7 | body { 8 | padding: 0 10px; 9 | margin: 10px auto 0; 10 | } 11 | 12 | .header { 13 | background: #ededed; 14 | background: -webkit-gradient(linear, left top, left bottom, 15 | color-stop(0%,#576a76), 16 | color-stop(100%,#4d5256)); 17 | background: -moz-linear-gradient(top, #576076 0%, #414e58 100%); 18 | background: -webkit-linear-gradient(top, #576a76 0%, #414e58 100%); 19 | background: -o-linear-gradient(top, #576a76 0%, #414e58 100%); 20 | background: -ms-linear-gradient(top, #576a76 0%, #414e58 100%); 21 | background: linear-gradient(top, #576a76 0%, #414e58 100%); 22 | border-bottom: 1px solid #fff; 23 | -moz-border-radius-topleft: 5px; 24 | -webkit-border-top-left-radius: 5px; 25 | border-top-left-radius: 5px; 26 | -moz-border-radius-topright: 5px; 27 | -webkit-border-top-right-radius:5px; 28 | border-top-right-radius: 5px; 29 | 30 | overflow:hidden; 31 | padding: 10px 5%; 32 | text-shadow:0 1px 0 #000; 33 | } 34 | 35 | .header h1 { 36 | color: #eee; 37 | float:left; 38 | font-size:1.2em; 39 | font-weight:normal; 40 | margin:2px 30px 0 0; 41 | } 42 | 43 | .header ul li { 44 | display: inline; 45 | } 46 | 47 | .header ul li a { 48 | color: #eee; 49 | text-decoration: none; 50 | margin-right: 10px; 51 | display: inline-block; 52 | padding: 4px 8px; 53 | -moz-border-radius: 10px; 54 | -webkit-border-radius:10px; 55 | border-radius: 10px; 56 | 57 | } 58 | 59 | .header ul li a:hover { 60 | background: rgba(255,255,255,0.1); 61 | } 62 | 63 | .header ul li a:active { 64 | -moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 65 | -webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2); 66 | box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 67 | } 68 | 69 | .header ul li.current a { 70 | background: rgba(255,255,255,0.1); 71 | -moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 72 | -webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2); 73 | box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 74 | color: #fff; 75 | } 76 | 77 | .header p.environment { 78 | clear: both; 79 | padding: 10px 0 0 0; 80 | color: #BBB; 81 | font-style: italic; 82 | float: right; 83 | } 84 | 85 | #main { 86 | padding: 10px 5%; 87 | background: #f9f9f9; 88 | border:1px solid #ccc; 89 | border-top:none; 90 | -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.2); 91 | -webkit-box-shadow:0 3px 10px rgba(0,0,0,0.2); 92 | box-shadow: 0 3px 10px rgba(0,0,0,0.2); 93 | overflow: hidden; 94 | } 95 | 96 | #main .logo { 97 | float: right; 98 | margin: 10px; 99 | } 100 | 101 | #main span.hl { 102 | background: #efefef; 103 | padding: 2px; 104 | } 105 | 106 | #main h1 { 107 | margin: 10px 0; 108 | font-size: 190%; 109 | font-weight: bold; 110 | color: #0080FF; 111 | } 112 | 113 | #main table { 114 | width: 100%; 115 | margin:0 0 10px; 116 | } 117 | 118 | #main table tr td, #main table tr th { 119 | border-bottom: 1px solid #ccc; 120 | padding: 6px; 121 | } 122 | 123 | #main table tr th { 124 | background: #efefef; 125 | color: #888; 126 | font-size: 80%; 127 | text-transform:uppercase; 128 | } 129 | 130 | #main table tr td.no-data { 131 | text-align: center; 132 | padding: 40px 0; 133 | color: #999; 134 | font-style: italic; 135 | font-size: 130%; 136 | } 137 | 138 | #main a { 139 | color: #111; 140 | } 141 | 142 | #main p { 143 | margin: 5px 0; 144 | } 145 | 146 | #main p.intro { 147 | margin-bottom: 15px; 148 | font-size: 85%; 149 | color: #999; 150 | margin-top: 0; 151 | line-height: 1.3; 152 | } 153 | 154 | #main h1.wi { 155 | margin-bottom: 5px; 156 | } 157 | 158 | #main p.sub { 159 | font-size: 95%; 160 | color: #999; 161 | } 162 | 163 | .experiment { 164 | background:#fff; 165 | border: 1px solid #eee; 166 | border-bottom:none; 167 | margin:30px 0; 168 | } 169 | 170 | .experiment_with_goal { 171 | margin: -32px 0 30px 0; 172 | } 173 | 174 | .experiment .experiment-header { 175 | background: #f4f4f4; 176 | background: -webkit-gradient(linear, left top, left bottom, 177 | color-stop(0%,#f4f4f4), 178 | color-stop(100%,#e0e0e0)); 179 | background: -moz-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 180 | background: -webkit-linear-gradient(top, #f4f4f4 0%, #e0e0e0 100%); 181 | background: -o-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 182 | background: -ms-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 183 | background: linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 184 | border-top:1px solid #fff; 185 | overflow:hidden; 186 | padding:0 10px; 187 | } 188 | 189 | .experiment h2 { 190 | color:#888; 191 | margin: 12px 0 12px 0; 192 | font-size: 1em; 193 | font-weight:bold; 194 | float:left; 195 | text-shadow:0 1px 0 rgba(255,255,255,0.8); 196 | } 197 | 198 | .experiment h2 .goal { 199 | font-style: italic; 200 | } 201 | 202 | .experiment h2 .version { 203 | font-style:italic; 204 | font-size:0.8em; 205 | color:#bbb; 206 | font-weight:normal; 207 | } 208 | 209 | .experiment table em{ 210 | font-style:italic; 211 | font-size:0.9em; 212 | color:#bbb; 213 | } 214 | 215 | .experiment table .totals td { 216 | background: #eee; 217 | font-weight: bold; 218 | } 219 | 220 | #footer { 221 | padding: 10px 5%; 222 | color: #999; 223 | font-size: 85%; 224 | line-height: 1.5; 225 | padding-top: 10px; 226 | } 227 | 228 | #footer p a { 229 | color: #999; 230 | } 231 | 232 | .inline-controls { 233 | float:right; 234 | } 235 | 236 | .inline-controls small { 237 | color: #888; 238 | font-size: 11px; 239 | } 240 | 241 | .inline-controls form { 242 | display: inline-block; 243 | font-size: 10px; 244 | line-height: 38px; 245 | } 246 | 247 | .inline-controls input { 248 | margin-left: 10px; 249 | } 250 | 251 | .worse, .better { 252 | color: #773F3F; 253 | font-size: 10px; 254 | font-weight:bold; 255 | } 256 | 257 | .better { 258 | color: #408C48; 259 | } 260 | 261 | .experiment a.button, .experiment button, .experiment input[type="submit"] { 262 | padding: 4px 10px; 263 | overflow: hidden; 264 | background: #d8dae0; 265 | -moz-box-shadow: 0 1px 0 rgba(0,0,0,0.5); 266 | -webkit-box-shadow:0 1px 0 rgba(0,0,0,0.5); 267 | box-shadow: 0 1px 0 rgba(0,0,0,0.5); 268 | border:none; 269 | -moz-border-radius: 30px; 270 | -webkit-border-radius:30px; 271 | border-radius: 30px; 272 | color:#2e3035; 273 | cursor: pointer; 274 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 275 | text-decoration: none; 276 | text-shadow:0 1px 0 rgba(255,255,255,0.8); 277 | -moz-user-select: none; 278 | -webkit-user-select:none; 279 | user-select: none; 280 | white-space: nowrap; 281 | } 282 | a.button:hover, button:hover, input[type="submit"]:hover, 283 | a.button:focus, button:focus, input[type="submit"]:focus{ 284 | background:#bbbfc7; 285 | } 286 | a.button:active, button:active, input[type="submit"]:active{ 287 | -moz-box-shadow: inset 0 0 4px #484d57; 288 | -webkit-box-shadow:inset 0 0 4px #484d57; 289 | box-shadow: inset 0 0 4px #484d57; 290 | position:relative; 291 | top:1px; 292 | } 293 | 294 | a.button.red, button.red, input[type="submit"].red, 295 | a.button.green, button.green, input[type="submit"].green { 296 | color:#fff; 297 | text-shadow:0 1px 0 rgba(0,0,0,0.4); 298 | } 299 | 300 | a.button.red, button.red, input[type="submit"].red { 301 | background:#a56d6d; 302 | } 303 | a.button.red:hover, button.red:hover, input[type="submit"].red:hover, 304 | a.button.red:focus, button.red:focus, input[type="submit"].red:focus { 305 | background:#895C5C; 306 | } 307 | a.button.green, button.green, input[type="submit"].green { 308 | background:#8daa92; 309 | } 310 | a.button.green:hover, button.green:hover, input[type="submit"].green:hover, 311 | a.button.green:focus, button.green:focus, input[type="submit"].green:focus { 312 | background:#768E7A; 313 | } 314 | 315 | .dashboard-controls input, .dashboard-controls select { 316 | padding: 10px; 317 | } 318 | 319 | .dashboard-controls-bottom { 320 | margin-top: 10px; 321 | } 322 | 323 | .pagination { 324 | text-align: center; 325 | font-size: 15px; 326 | } 327 | 328 | .pagination a, .paginaton span { 329 | display: inline-block; 330 | padding: 5px; 331 | } 332 | 333 | .divider { 334 | display: inline-block; 335 | margin-left: 10px; 336 | } 337 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/_controls.erb: -------------------------------------------------------------------------------- 1 | <% if experiment.has_winner? %> 2 |
" method='post' onclick="return confirmReopen()"> 3 | 4 |
5 | <% else %> 6 | <% if experiment.cohorting_disabled? %> 7 |
" method='post' onclick="return confirmEnableCohorting()"> 8 | 9 | 10 |
11 | <% else %> 12 |
" method='post' onclick="return confirmDisableCohorting()"> 13 | 14 | 15 |
16 | <% end %> 17 | <% end %> 18 | | 19 | <% if experiment.start_time %> 20 |
" method='post' onclick="return confirmReset()"> 21 | 22 |
23 | <% else%> 24 |
" method='post'> 25 | 26 |
27 | <% end %> 28 |
" method='post' onclick="return confirmDelete()"> 29 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/_experiment.erb: -------------------------------------------------------------------------------- 1 | <% unless goal.nil? %> 2 | <% experiment_class = "experiment experiment_with_goal" %> 3 | <% else %> 4 | <% experiment_class = "experiment" %> 5 | <% end %> 6 | 7 | <% experiment.calc_winning_alternatives %> 8 | <% 9 | extra_columns = [] 10 | experiment.alternatives.each do |alternative| 11 | extra_info = alternative.extra_info || {} 12 | extra_columns += extra_info.keys 13 | end 14 | 15 | extra_columns.uniq! 16 | summary_texts = {} 17 | extra_columns.each do |column| 18 | extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] } 19 | 20 | if extra_infos.length > 0 && extra_infos.all? { |extra_info| extra_info[column].kind_of?(Numeric) } 21 | summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]} 22 | else 23 | summary_texts[column] = "N/A" 24 | end 25 | end 26 | %> 27 | 28 | 29 |
30 |
31 |

32 | Experiment: <%= experiment.name %> 33 | <% if experiment.version > 1 %>v<%= experiment.version %><% end %> 34 | <% unless goal.nil? %>Goal:<%= goal %><% end %> 35 | <% metrics = @metrics.select {|metric| metric.experiments.include? experiment} %> 36 | <% unless metrics.empty? %> 37 | Metrics:<%= metrics.map(&:name).join(', ') %> 38 | <% end %> 39 |

40 | 41 | <% if goal.nil? %> 42 |
43 | <%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %> 44 | <%= erb :_controls, :locals => {:experiment => experiment} %> 45 |
46 | <% end %> 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | <% extra_columns.each do |column| %> 56 | 57 | <% end %> 58 | 66 | 67 | 68 | 69 | <% total_participants = total_completed = total_unfinished = 0 %> 70 | <% experiment.alternatives.each do |alternative| %> 71 | 72 | 82 | 83 | 84 | 85 | 99 | 108 | <% extra_columns.each do |column| %> 109 | 110 | <% end %> 111 | 120 | 134 | 135 | 136 | <% total_participants += alternative.participant_count %> 137 | <% total_unfinished += alternative.unfinished_count %> 138 | <% total_completed += alternative.completed_count(goal) %> 139 | <% end %> 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | <% extra_columns.each do |column| %> 148 | 151 | <% end %> 152 | 153 | 154 | 155 |
Alternative NameParticipantsNon-finishedCompletedConversion Rate<%= column %> 59 |
60 | 64 |
65 |
Finish
73 | <%= alternative.name %> 74 | <% if alternative.control? %> 75 | control 76 | <% end %> 77 |
78 | 79 | 80 |
81 |
<%= alternative.participant_count %><%= alternative.unfinished_count %><%= alternative.completed_count(goal) %> 86 | <%= number_to_percentage(alternative.conversion_rate(goal)) %>% 87 | <% if experiment.control.conversion_rate(goal) > 0 && !alternative.control? %> 88 | <% if alternative.conversion_rate(goal) > experiment.control.conversion_rate(goal) %> 89 | 90 | +<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>% 91 | 92 | <% elsif alternative.conversion_rate(goal) < experiment.control.conversion_rate(goal) %> 93 | 94 | <%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>% 95 | 96 | <% end %> 97 | <% end %> 98 | <%= alternative.extra_info && alternative.extra_info[column] %> 112 |
113 | <%= confidence_level(alternative.z_score(goal)) %> 114 |
115 |
116 |
117 | <%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>% 118 |
119 |
121 | <% if experiment.has_winner? %> 122 | <% if experiment.winner.name == alternative.name %> 123 | Winner 124 | <% else %> 125 | Loser 126 | <% end %> 127 | <% else %> 128 |
129 | 130 | 131 |
132 | <% end %> 133 |
Totals<%= total_participants %><%= total_unfinished %><%= total_completed %>N/A 149 | <%= summary_texts[column] %> 150 | N/AN/A
156 |
157 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/_experiment_with_goal_header.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | <%= experiment.start_time ? experiment.start_time.strftime('%Y-%m-%d') : 'Unknown' %> 5 | <%= erb :_controls, :locals => {:experiment => experiment} %> 6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/index.erb: -------------------------------------------------------------------------------- 1 | <% if @experiments.any? %> 2 |

The list below contains all the registered experiments along with the number of test participants, completed and conversion rate currently in the system.

3 | 4 |
5 | 6 | 7 | 8 | 9 |
10 | 11 | <% paginated(@experiments).each do |experiment| %> 12 | <% if experiment.goals.empty? %> 13 | <%= erb :_experiment, :locals => {:goal => nil, :experiment => experiment} %> 14 | <% else %> 15 | <%= erb :_experiment_with_goal_header, :locals => {:experiment => experiment} %> 16 | <% experiment.goals.each do |g| %> 17 | <%= erb :_experiment, :locals => {:goal => g, :experiment => experiment} %> 18 | <% end %> 19 | <% end %> 20 | <% end %> 21 | 22 | 25 | <% else %> 26 |

No experiments have started yet, you need to define them in your code and introduce them to your users.

27 |

Check out the Readme for more help getting started.

28 | <% end %> 29 | 30 |
31 |
" method='post'> 32 | 33 | 39 | 40 |
41 |
42 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Split 11 | 12 | 13 | 14 |
15 |

Split Dashboard

16 |

<%= @current_env %>

17 |
18 | 19 |
20 | <%= yield %> 21 |
22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/split/encapsulated_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "split/helper" 4 | 5 | # Split's helper exposes all kinds of methods we don't want to 6 | # mix into our model classes. 7 | # 8 | # This module exposes only two methods: 9 | # - ab_test() 10 | # - ab_finished() 11 | # that can safely be mixed into any class. 12 | # 13 | # Passes the instance of the class that it's mixed into to the 14 | # Split persistence adapter as context. 15 | # 16 | module Split 17 | module EncapsulatedHelper 18 | class ContextShim 19 | include Split::Helper 20 | public :ab_test, :ab_finished 21 | 22 | def initialize(context) 23 | @context = context 24 | end 25 | 26 | def params 27 | request.params if request && request.respond_to?(:params) 28 | end 29 | 30 | def request 31 | @context.request if @context.respond_to?(:request) 32 | end 33 | 34 | def ab_user 35 | @ab_user ||= Split::User.new(@context) 36 | end 37 | end 38 | 39 | def ab_test(*arguments, &block) 40 | split_context_shim.ab_test(*arguments, &block) 41 | end 42 | 43 | private 44 | # instantiate and memoize a context shim in case of multiple ab_test* calls 45 | def split_context_shim 46 | @split_context_shim ||= ContextShim.new(self) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/split/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Engine < ::Rails::Engine 5 | initializer "split" do |app| 6 | if Split.configuration.include_rails_helper 7 | ActiveSupport.on_load(:action_controller) do 8 | ::ActionController::Base.send :include, Split::Helper 9 | ::ActionController::Base.helper Split::Helper 10 | ::ActionController::Base.send :include, Split::CombinedExperimentsHelper 11 | ::ActionController::Base.helper Split::CombinedExperimentsHelper 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/split/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class InvalidPersistenceAdapterError < StandardError; end 5 | class ExperimentNotFound < StandardError; end 6 | class InvalidExperimentsFormatError < StandardError; end 7 | end 8 | -------------------------------------------------------------------------------- /lib/split/experiment_catalog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class ExperimentCatalog 5 | # Return all experiments 6 | def self.all 7 | # Call compact to prevent nil experiments from being returned -- seems to happen during gem upgrades 8 | Split.redis.smembers(:experiments).map { |e| find(e) }.compact 9 | end 10 | 11 | # Return experiments without a winner (considered "active") first 12 | def self.all_active_first 13 | all.partition { |e| not e.winner }.map { |es| es.sort_by(&:name) }.flatten 14 | end 15 | 16 | def self.find(name) 17 | Experiment.find(name) 18 | end 19 | 20 | def self.find_or_initialize(metric_descriptor, control = nil, *alternatives) 21 | # Check if array is passed to ab_test 22 | # e.g. ab_test('name', ['Alt 1', 'Alt 2', 'Alt 3']) 23 | if control.is_a?(Array) && alternatives.length.zero? 24 | control, alternatives = control.first, control[1..-1] 25 | end 26 | 27 | experiment_name_with_version, goals = normalize_experiment(metric_descriptor) 28 | experiment_name = experiment_name_with_version.to_s.split(":")[0] 29 | Split::Experiment.new(experiment_name, 30 | alternatives: [control].compact + alternatives, goals: goals) 31 | end 32 | 33 | def self.find_or_create(metric_descriptor, control = nil, *alternatives) 34 | experiment = find_or_initialize(metric_descriptor, control, *alternatives) 35 | experiment.save 36 | end 37 | 38 | def self.normalize_experiment(metric_descriptor) 39 | if Hash === metric_descriptor 40 | experiment_name = metric_descriptor.keys.first 41 | goals = Array(metric_descriptor.values.first) 42 | else 43 | experiment_name = metric_descriptor 44 | goals = [] 45 | end 46 | return experiment_name, goals 47 | end 48 | private_class_method :normalize_experiment 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/split/extensions/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class String 4 | # Constatntize is often provided by ActiveSupport, but ActiveSupport is not a dependency of Split. 5 | unless method_defined?(:constantize) 6 | def constantize 7 | names = self.split("::") 8 | names.shift if names.empty? || names.first.empty? 9 | 10 | constant = Object 11 | names.each do |name| 12 | constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name) 13 | end 14 | constant 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/split/goals_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class GoalsCollection 5 | def initialize(experiment_name, goals = nil) 6 | @experiment_name = experiment_name 7 | @goals = goals 8 | end 9 | 10 | def load_from_redis 11 | Split.redis.lrange(goals_key, 0, -1) 12 | end 13 | 14 | def load_from_configuration 15 | goals = Split.configuration.experiment_for(@experiment_name)[:goals] 16 | 17 | if goals 18 | goals.flatten 19 | else 20 | [] 21 | end 22 | end 23 | 24 | def save 25 | return false if @goals.nil? 26 | RedisInterface.new.persist_list(goals_key, @goals) 27 | end 28 | 29 | def validate! 30 | unless @goals.nil? || @goals.kind_of?(Array) 31 | raise ArgumentError, "Goals must be an array" 32 | end 33 | end 34 | 35 | def delete 36 | Split.redis.del(goals_key) 37 | end 38 | 39 | private 40 | def goals_key 41 | "#{@experiment_name}:goals" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/split/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Helper 5 | OVERRIDE_PARAM_NAME = "ab_test" 6 | 7 | module_function 8 | 9 | def ab_test(metric_descriptor, control = nil, *alternatives) 10 | begin 11 | experiment = ExperimentCatalog.find_or_initialize(metric_descriptor, control, *alternatives) 12 | alternative = if Split.configuration.enabled && !exclude_visitor? 13 | experiment.save 14 | raise(Split::InvalidExperimentsFormatError) unless (Split.configuration.experiments || {}).fetch(experiment.name.to_sym, {})[:combined_experiments].nil? 15 | trial = Trial.new(user: ab_user, experiment: experiment, 16 | override: override_alternative(experiment.name), exclude: exclude_visitor?, 17 | disabled: split_generically_disabled?) 18 | alt = trial.choose!(self) 19 | alt ? alt.name : nil 20 | else 21 | control_variable(experiment.control) 22 | end 23 | rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e 24 | raise(e) unless Split.configuration.db_failover 25 | Split.configuration.db_failover_on_db_error.call(e) 26 | 27 | if Split.configuration.db_failover_allow_parameter_override 28 | alternative = override_alternative(experiment.name) if override_present?(experiment.name) 29 | alternative = control_variable(experiment.control) if split_generically_disabled? 30 | end 31 | ensure 32 | alternative ||= control_variable(experiment.control) 33 | end 34 | 35 | if block_given? 36 | metadata = experiment.metadata[alternative] if experiment.metadata 37 | yield(alternative, metadata || {}) 38 | else 39 | alternative 40 | end 41 | end 42 | 43 | def reset!(experiment) 44 | ab_user.delete(experiment.key) 45 | end 46 | 47 | def finish_experiment(experiment, options = { reset: true }) 48 | return false if active_experiments[experiment.name].nil? 49 | return true if experiment.has_winner? 50 | should_reset = experiment.resettable? && options[:reset] 51 | if ab_user[experiment.finished_key] && !should_reset 52 | true 53 | else 54 | alternative_name = ab_user[experiment.key] 55 | trial = Trial.new( 56 | user: ab_user, 57 | experiment: experiment, 58 | alternative: alternative_name, 59 | goals: options[:goals], 60 | ) 61 | 62 | trial.complete!(self) 63 | 64 | if should_reset 65 | reset!(experiment) 66 | else 67 | ab_user[experiment.finished_key] = true 68 | end 69 | end 70 | end 71 | 72 | def ab_finished(metric_descriptor, options = { reset: true }) 73 | return if exclude_visitor? || Split.configuration.disabled? 74 | metric_descriptor, goals = normalize_metric(metric_descriptor) 75 | experiments = Metric.possible_experiments(metric_descriptor) 76 | 77 | if experiments.any? 78 | experiments.each do |experiment| 79 | next if override_present?(experiment.key) 80 | finish_experiment(experiment, options.merge(goals: goals)) 81 | end 82 | end 83 | rescue => e 84 | raise unless Split.configuration.db_failover 85 | Split.configuration.db_failover_on_db_error.call(e) 86 | end 87 | 88 | def ab_record_extra_info(metric_descriptor, key, value = 1) 89 | return if exclude_visitor? || Split.configuration.disabled? || value.nil? 90 | metric_descriptor, _ = normalize_metric(metric_descriptor) 91 | experiments = Metric.possible_experiments(metric_descriptor) 92 | 93 | if experiments.any? 94 | experiments.each do |experiment| 95 | alternative_name = ab_user[experiment.key] 96 | 97 | if alternative_name 98 | alternative = experiment.alternatives.find { |alt| alt.name == alternative_name } 99 | alternative.record_extra_info(key, value) if alternative 100 | end 101 | end 102 | end 103 | rescue => e 104 | raise unless Split.configuration.db_failover 105 | Split.configuration.db_failover_on_db_error.call(e) 106 | end 107 | 108 | def ab_active_experiments 109 | ab_user.active_experiments 110 | rescue => e 111 | raise unless Split.configuration.db_failover 112 | Split.configuration.db_failover_on_db_error.call(e) 113 | end 114 | 115 | def override_present?(experiment_name) 116 | override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name) 117 | end 118 | 119 | def override_alternative(experiment_name) 120 | override_alternative_by_params(experiment_name) || override_alternative_by_cookies(experiment_name) 121 | end 122 | 123 | def override_alternative_by_params(experiment_name) 124 | params_present? && params[OVERRIDE_PARAM_NAME] && params[OVERRIDE_PARAM_NAME][experiment_name] 125 | end 126 | 127 | def override_alternative_by_cookies(experiment_name) 128 | return unless request_present? 129 | 130 | if request.cookies && request.cookies.key?("split_override") 131 | experiments = JSON.parse(request.cookies["split_override"]) rescue {} 132 | experiments[experiment_name] 133 | end 134 | end 135 | 136 | def split_generically_disabled? 137 | params_present? && params["SPLIT_DISABLE"] 138 | end 139 | 140 | def ab_user 141 | @ab_user ||= User.new(self) 142 | end 143 | 144 | def exclude_visitor? 145 | request_present? && (instance_exec(request, &Split.configuration.ignore_filter) || is_ignored_ip_address? || is_robot? || is_preview?) 146 | end 147 | 148 | def is_robot? 149 | request_present? && request.user_agent =~ Split.configuration.robot_regex 150 | end 151 | 152 | def is_preview? 153 | request_present? && defined?(request.headers) && request.headers["x-purpose"] == "preview" 154 | end 155 | 156 | def is_ignored_ip_address? 157 | return false if Split.configuration.ignore_ip_addresses.empty? 158 | 159 | Split.configuration.ignore_ip_addresses.each do |ip| 160 | return true if request_present? && (request.ip == ip || (ip.class == Regexp && request.ip =~ ip)) 161 | end 162 | false 163 | end 164 | 165 | def params_present? 166 | defined?(params) && params 167 | end 168 | 169 | def request_present? 170 | defined?(request) && request 171 | end 172 | 173 | def active_experiments 174 | ab_user.active_experiments 175 | end 176 | 177 | def normalize_metric(metric_descriptor) 178 | if Hash === metric_descriptor 179 | experiment_name = metric_descriptor.keys.first 180 | goals = Array(metric_descriptor.values.first) 181 | else 182 | experiment_name = metric_descriptor 183 | goals = [] 184 | end 185 | return experiment_name, goals 186 | end 187 | 188 | def control_variable(control) 189 | Hash === control ? control.keys.first.to_s : control.to_s 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/split/metric.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Metric 5 | attr_accessor :name 6 | attr_accessor :experiments 7 | 8 | def initialize(attrs = {}) 9 | attrs.each do |key, value| 10 | if self.respond_to?("#{key}=") 11 | self.send("#{key}=", value) 12 | end 13 | end 14 | end 15 | 16 | def self.load_from_redis(name) 17 | metric = Split.redis.hget(:metrics, name) 18 | if metric 19 | experiment_names = metric.split(",") 20 | 21 | experiments = experiment_names.collect do |experiment_name| 22 | Split::ExperimentCatalog.find(experiment_name) 23 | end 24 | 25 | Split::Metric.new(name: name, experiments: experiments) 26 | else 27 | nil 28 | end 29 | end 30 | 31 | def self.load_from_configuration(name) 32 | metrics = Split.configuration.metrics 33 | if metrics && metrics[name] 34 | Split::Metric.new(experiments: metrics[name], name: name) 35 | else 36 | nil 37 | end 38 | end 39 | 40 | def self.find(name) 41 | name = name.intern if name.is_a?(String) 42 | metric = load_from_configuration(name) 43 | metric = load_from_redis(name) if metric.nil? 44 | metric 45 | end 46 | 47 | def self.find_or_create(attrs) 48 | metric = find(attrs[:name]) 49 | unless metric 50 | metric = new(attrs) 51 | metric.save 52 | end 53 | metric 54 | end 55 | 56 | def self.all 57 | redis_metrics = Split.redis.hgetall(:metrics).collect do |key, value| 58 | find(key) 59 | end 60 | configuration_metrics = Split.configuration.metrics.collect do |key, value| 61 | new(name: key, experiments: value) 62 | end 63 | redis_metrics | configuration_metrics 64 | end 65 | 66 | def self.possible_experiments(metric_name) 67 | experiments = [] 68 | metric = Split::Metric.find(metric_name) 69 | if metric 70 | experiments << metric.experiments 71 | end 72 | experiment = Split::ExperimentCatalog.find(metric_name) 73 | if experiment 74 | experiments << experiment 75 | end 76 | experiments.flatten 77 | end 78 | 79 | def save 80 | Split.redis.hset(:metrics, name, experiments.map(&:name).join(",")) 81 | end 82 | 83 | def complete! 84 | experiments.each do |experiment| 85 | experiment.complete! 86 | end 87 | end 88 | 89 | def self.normalize_metric(label) 90 | if Hash === label 91 | metric_name = label.keys.first 92 | goals = label.values.first 93 | else 94 | metric_name = label 95 | goals = [] 96 | end 97 | return metric_name, goals 98 | end 99 | private_class_method :normalize_metric 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/split/persistence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Persistence 5 | require "split/persistence/cookie_adapter" 6 | require "split/persistence/dual_adapter" 7 | require "split/persistence/redis_adapter" 8 | require "split/persistence/session_adapter" 9 | 10 | ADAPTERS = { 11 | cookie: Split::Persistence::CookieAdapter, 12 | session: Split::Persistence::SessionAdapter, 13 | redis: Split::Persistence::RedisAdapter, 14 | dual_adapter: Split::Persistence::DualAdapter 15 | }.freeze 16 | 17 | def self.adapter 18 | if persistence_config.is_a?(Symbol) 19 | ADAPTERS.fetch(persistence_config) { raise Split::InvalidPersistenceAdapterError } 20 | else 21 | persistence_config 22 | end 23 | end 24 | 25 | def self.persistence_config 26 | Split.configuration.persistence 27 | end 28 | private_class_method :persistence_config 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/split/persistence/cookie_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Split 6 | module Persistence 7 | class CookieAdapter 8 | def initialize(context) 9 | @context = context 10 | @request, @response = context.request, context.response 11 | @cookies = @request.cookies 12 | @expires = Time.now + cookie_length_config 13 | end 14 | 15 | def [](key) 16 | hash[key.to_s] 17 | end 18 | 19 | def []=(key, value) 20 | set_cookie(hash.merge!(key.to_s => value)) 21 | end 22 | 23 | def delete(key) 24 | set_cookie(hash.tap { |h| h.delete(key.to_s) }) 25 | end 26 | 27 | def keys 28 | hash.keys 29 | end 30 | 31 | private 32 | def set_cookie(value = {}) 33 | cookie_key = :split.to_s 34 | cookie_value = default_options.merge(value: JSON.generate(value)) 35 | if action_dispatch? 36 | # The "send" is necessary when we call ab_test from the controller 37 | # and thus @context is a rails controller, because then "cookies" is 38 | # a private method. 39 | @context.send(:cookies)[cookie_key] = cookie_value 40 | else 41 | set_cookie_via_rack(cookie_key, cookie_value) 42 | end 43 | end 44 | 45 | def default_options 46 | { expires: @expires, path: "/", domain: cookie_domain_config }.compact 47 | end 48 | 49 | def set_cookie_via_rack(key, value) 50 | headers = @response.respond_to?(:header) ? @response.header : @response.headers 51 | delete_cookie_header!(headers, key, value) 52 | Rack::Utils.set_cookie_header!(headers, key, value) 53 | end 54 | 55 | # Use Rack::Utils#make_delete_cookie_header after Rack 2.0.0 56 | def delete_cookie_header!(header, key, value) 57 | cookie_header = header["Set-Cookie"] 58 | case cookie_header 59 | when nil, "" 60 | cookies = [] 61 | when String 62 | cookies = cookie_header.split("\n") 63 | when Array 64 | cookies = cookie_header 65 | end 66 | 67 | cookies.reject! { |cookie| cookie =~ /\A#{Rack::Utils.escape(key)}=/ } 68 | header["Set-Cookie"] = cookies.join("\n") 69 | end 70 | 71 | def hash 72 | @hash ||= if cookies = @cookies[:split.to_s] 73 | begin 74 | parsed = JSON.parse(cookies) 75 | parsed.is_a?(Hash) ? parsed : {} 76 | rescue JSON::ParserError 77 | {} 78 | end 79 | else 80 | {} 81 | end 82 | end 83 | 84 | def cookie_length_config 85 | Split.configuration.persistence_cookie_length 86 | end 87 | 88 | def cookie_domain_config 89 | Split.configuration.persistence_cookie_domain 90 | end 91 | 92 | def action_dispatch? 93 | defined?(Rails) && @response.is_a?(ActionDispatch::Response) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/split/persistence/dual_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Persistence 5 | class DualAdapter 6 | def self.with_config(options = {}) 7 | self.config.merge!(options) 8 | self 9 | end 10 | 11 | def self.config 12 | @config ||= {} 13 | end 14 | 15 | def initialize(context) 16 | if logged_in = self.class.config[:logged_in] 17 | else 18 | raise "Please configure :logged_in" 19 | end 20 | if logged_in_adapter = self.class.config[:logged_in_adapter] 21 | else 22 | raise "Please configure :logged_in_adapter" 23 | end 24 | if logged_out_adapter = self.class.config[:logged_out_adapter] 25 | else 26 | raise "Please configure :logged_out_adapter" 27 | end 28 | 29 | @fallback_to_logged_out_adapter = 30 | self.class.config[:fallback_to_logged_out_adapter] || false 31 | @logged_in = logged_in.call(context) 32 | @logged_in_adapter = logged_in_adapter.new(context) 33 | @logged_out_adapter = logged_out_adapter.new(context) 34 | @active_adapter = @logged_in ? @logged_in_adapter : @logged_out_adapter 35 | end 36 | 37 | def keys 38 | if @fallback_to_logged_out_adapter 39 | (@logged_in_adapter.keys + @logged_out_adapter.keys).uniq 40 | else 41 | @active_adapter.keys 42 | end 43 | end 44 | 45 | def [](key) 46 | if @fallback_to_logged_out_adapter 47 | @logged_in && @logged_in_adapter[key] || @logged_out_adapter[key] 48 | else 49 | @active_adapter[key] 50 | end 51 | end 52 | 53 | def []=(key, value) 54 | if @fallback_to_logged_out_adapter 55 | @logged_in_adapter[key] = value if @logged_in 56 | old_value = @logged_out_adapter[key] 57 | @logged_out_adapter[key] = value 58 | 59 | decrement_participation(key, old_value) if decrement_participation?(old_value, value) 60 | else 61 | @active_adapter[key] = value 62 | end 63 | end 64 | 65 | def delete(key) 66 | if @fallback_to_logged_out_adapter 67 | @logged_in_adapter.delete(key) 68 | @logged_out_adapter.delete(key) 69 | else 70 | @active_adapter.delete(key) 71 | end 72 | end 73 | 74 | private 75 | def decrement_participation?(old_value, value) 76 | !old_value.nil? && !value.nil? && old_value != value 77 | end 78 | 79 | def decrement_participation(key, value) 80 | Split.redis.hincrby("#{key}:#{value}", "participant_count", -1) 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/split/persistence/redis_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Persistence 5 | class RedisAdapter 6 | DEFAULT_CONFIG = { namespace: "persistence" }.freeze 7 | 8 | attr_reader :redis_key 9 | 10 | def initialize(context, key = nil) 11 | if key 12 | @redis_key = "#{self.class.config[:namespace]}:#{key}" 13 | elsif lookup_by = self.class.config[:lookup_by] 14 | if lookup_by.respond_to?(:call) 15 | key_frag = lookup_by.call(context) 16 | else 17 | key_frag = context.send(lookup_by) 18 | end 19 | @redis_key = "#{self.class.config[:namespace]}:#{key_frag}" 20 | else 21 | raise "Please configure lookup_by" 22 | end 23 | end 24 | 25 | def [](field) 26 | Split.redis.hget(redis_key, field) 27 | end 28 | 29 | def []=(field, value) 30 | Split.redis.hset(redis_key, field, value.to_s) 31 | expire_seconds = self.class.config[:expire_seconds] 32 | Split.redis.expire(redis_key, expire_seconds) if expire_seconds 33 | end 34 | 35 | def delete(field) 36 | Split.redis.hdel(redis_key, field) 37 | end 38 | 39 | def keys 40 | Split.redis.hkeys(redis_key) 41 | end 42 | 43 | def self.find(user_id) 44 | new(nil, user_id) 45 | end 46 | 47 | def self.with_config(options = {}) 48 | self.config.merge!(options) 49 | self 50 | end 51 | 52 | def self.config 53 | @config ||= DEFAULT_CONFIG.dup 54 | end 55 | 56 | def self.reset_config! 57 | @config = DEFAULT_CONFIG.dup 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/split/persistence/session_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Persistence 5 | class SessionAdapter 6 | def initialize(context) 7 | @session = context.session 8 | @session[:split] ||= {} 9 | end 10 | 11 | def [](key) 12 | @session[:split][key] 13 | end 14 | 15 | def []=(key, value) 16 | @session[:split][key] = value 17 | end 18 | 19 | def delete(key) 20 | @session[:split].delete(key) 21 | end 22 | 23 | def keys 24 | @session[:split].keys 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/split/redis_interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | # Simplifies the interface to Redis. 5 | class RedisInterface 6 | def initialize 7 | self.redis = Split.redis 8 | end 9 | 10 | def persist_list(list_name, list_values) 11 | if list_values.length > 0 12 | redis.multi do |multi| 13 | tmp_list = "#{list_name}_tmp" 14 | tmp_list += redis_namespace_used? ? "{#{Split.redis.namespace}:#{list_name}}" : "{#{list_name}}" 15 | multi.rpush(tmp_list, list_values) 16 | multi.rename(tmp_list, list_name) 17 | end 18 | end 19 | 20 | list_values 21 | end 22 | 23 | def add_to_set(set_name, value) 24 | return redis.sadd?(set_name, value) if redis.respond_to?(:sadd?) 25 | 26 | redis.sadd(set_name, value) 27 | end 28 | 29 | private 30 | attr_accessor :redis 31 | 32 | def redis_namespace_used? 33 | Redis.const_defined?("Namespace") && Split.redis.is_a?(Redis::Namespace) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/split/trial.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Trial 5 | attr_accessor :goals 6 | attr_accessor :experiment 7 | attr_writer :metadata 8 | 9 | def initialize(attrs = {}) 10 | self.experiment = attrs.delete(:experiment) 11 | self.alternative = attrs.delete(:alternative) 12 | self.metadata = attrs.delete(:metadata) 13 | self.goals = attrs.delete(:goals) || [] 14 | 15 | @user = attrs.delete(:user) 16 | @options = attrs 17 | 18 | @alternative_chosen = false 19 | end 20 | 21 | def metadata 22 | @metadata ||= experiment.metadata[alternative.name] if experiment.metadata 23 | end 24 | 25 | def alternative 26 | @alternative ||= if @experiment.has_winner? 27 | @experiment.winner 28 | end 29 | end 30 | 31 | def alternative=(alternative) 32 | @alternative = if alternative.kind_of?(Split::Alternative) 33 | alternative 34 | else 35 | @experiment.alternatives.find { |a| a.name == alternative } 36 | end 37 | end 38 | 39 | def complete!(context = nil) 40 | if alternative 41 | if Array(goals).empty? 42 | alternative.increment_completion 43 | else 44 | Array(goals).each { |g| alternative.increment_completion(g) } 45 | end 46 | 47 | run_callback context, Split.configuration.on_trial_complete 48 | end 49 | end 50 | 51 | # Choose an alternative, add a participant, and save the alternative choice on the user. This 52 | # method is guaranteed to only run once, and will skip the alternative choosing process if run 53 | # a second time. 54 | def choose!(context = nil) 55 | @user.cleanup_old_experiments! 56 | # Only run the process once 57 | return alternative if @alternative_chosen 58 | 59 | new_participant = @user[@experiment.key].nil? 60 | if override_is_alternative? 61 | self.alternative = @options[:override] 62 | if should_store_alternative? && !@user[@experiment.key] 63 | self.alternative.increment_participation 64 | end 65 | elsif @options[:disabled] || Split.configuration.disabled? 66 | self.alternative = @experiment.control 67 | elsif @experiment.has_winner? 68 | self.alternative = @experiment.winner 69 | else 70 | cleanup_old_versions 71 | 72 | if exclude_user? 73 | self.alternative = @experiment.control 74 | else 75 | self.alternative = @user[@experiment.key] 76 | if alternative.nil? 77 | if @experiment.cohorting_disabled? 78 | self.alternative = @experiment.control 79 | else 80 | self.alternative = @experiment.next_alternative 81 | 82 | # Increment the number of participants since we are actually choosing a new alternative 83 | self.alternative.increment_participation 84 | 85 | run_callback context, Split.configuration.on_trial_choose 86 | end 87 | end 88 | end 89 | end 90 | 91 | new_participant_and_cohorting_disabled = new_participant && @experiment.cohorting_disabled? 92 | 93 | @user[@experiment.key] = alternative.name unless @experiment.has_winner? || !should_store_alternative? || new_participant_and_cohorting_disabled 94 | @alternative_chosen = true 95 | run_callback context, Split.configuration.on_trial unless @options[:disabled] || Split.configuration.disabled? || new_participant_and_cohorting_disabled 96 | alternative 97 | end 98 | 99 | private 100 | def run_callback(context, callback_name) 101 | context.send(callback_name, self) if callback_name && context.respond_to?(callback_name, true) 102 | end 103 | 104 | def override_is_alternative? 105 | @experiment.alternatives.map(&:name).include?(@options[:override]) 106 | end 107 | 108 | def should_store_alternative? 109 | if @options[:override] || @options[:disabled] 110 | Split.configuration.store_override 111 | else 112 | !exclude_user? 113 | end 114 | end 115 | 116 | def cleanup_old_versions 117 | if @experiment.version > 0 118 | @user.cleanup_old_versions!(@experiment) 119 | end 120 | end 121 | 122 | def exclude_user? 123 | @options[:exclude] || @experiment.start_time.nil? || @user.max_experiments_reached?(@experiment.key) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/split/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module Split 6 | class User 7 | extend Forwardable 8 | def_delegators :@user, :keys, :[], :[]=, :delete 9 | attr_reader :user 10 | 11 | def initialize(context, adapter = nil) 12 | @user = adapter || Split::Persistence.adapter.new(context) 13 | @cleaned_up = false 14 | end 15 | 16 | def cleanup_old_experiments! 17 | return if @cleaned_up 18 | keys_without_finished(user.keys).each do |key| 19 | experiment = ExperimentCatalog.find key_without_version(key) 20 | if experiment.nil? || experiment.has_winner? || experiment.start_time.nil? 21 | user.delete key 22 | user.delete Experiment.finished_key(key) 23 | end 24 | end 25 | @cleaned_up = true 26 | end 27 | 28 | def max_experiments_reached?(experiment_key) 29 | if Split.configuration.allow_multiple_experiments == "control" 30 | experiments = active_experiments 31 | experiment_key_without_version = key_without_version(experiment_key) 32 | count_control = experiments.count { |k, v| k == experiment_key_without_version || v == "control" } 33 | experiments.size > count_control 34 | else 35 | !Split.configuration.allow_multiple_experiments && 36 | keys_without_experiment(user.keys, experiment_key).length > 0 37 | end 38 | end 39 | 40 | def cleanup_old_versions!(experiment) 41 | keys = user.keys.select { |k| key_without_version(k) == experiment.name } 42 | keys_without_experiment(keys, experiment.key).each { |key| user.delete(key) } 43 | end 44 | 45 | def active_experiments 46 | experiment_pairs = {} 47 | keys_without_finished(user.keys).each do |key| 48 | Metric.possible_experiments(key_without_version(key)).each do |experiment| 49 | if !experiment.has_winner? 50 | experiment_pairs[key_without_version(key)] = user[key] 51 | end 52 | end 53 | end 54 | experiment_pairs 55 | end 56 | 57 | def self.find(user_id, adapter) 58 | adapter = adapter.is_a?(Symbol) ? Split::Persistence::ADAPTERS[adapter] : adapter 59 | 60 | if adapter.respond_to?(:find) 61 | User.new(nil, adapter.find(user_id)) 62 | else 63 | nil 64 | end 65 | end 66 | 67 | private 68 | def keys_without_experiment(keys, experiment_key) 69 | keys.reject { |k| k.match(Regexp.new("^#{experiment_key}(:finished)?$")) } 70 | end 71 | 72 | def keys_without_finished(keys) 73 | keys.reject { |k| k.include?(":finished") } 74 | end 75 | 76 | def key_without_version(key) 77 | key.split(/\:\d(?!\:)/)[0] 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/split/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | VERSION = "4.0.4" 5 | end 6 | -------------------------------------------------------------------------------- /lib/split/zscore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Zscore 5 | include Math 6 | 7 | def self.calculate(p1, n1, p2, n2) 8 | # p_1 = Pa = proportion of users who converted within the experiment split (conversion rate) 9 | # p_2 = Pc = proportion of users who converted within the control split (conversion rate) 10 | # n_1 = Na = the number of impressions within the experiment split 11 | # n_2 = Nc = the number of impressions within the control split 12 | # s_1 = SEa = standard error of p_1, the estiamte of the mean 13 | # s_2 = SEc = standard error of p_2, the estimate of the control 14 | # s_p = SEp = standard error of p_1 - p_2, assuming a pooled variance 15 | # s_unp = SEunp = standard error of p_1 - p_2, assuming unpooled variance 16 | 17 | p_1 = p1.to_f 18 | p_2 = p2.to_f 19 | 20 | n_1 = n1.to_f 21 | n_2 = n2.to_f 22 | 23 | # Perform checks on data to make sure we can validly run our confidence tests 24 | if n_1 < 30 || n_2 < 30 25 | error = "Needs 30+ participants." 26 | return error 27 | elsif p_1 * n_1 < 5 || p_2 * n_2 < 5 28 | error = "Needs 5+ conversions." 29 | return error 30 | end 31 | 32 | # Formula for standard error: root(pq/n) = root(p(1-p)/n) 33 | s_1 = Math.sqrt((p_1)*(1-p_1)/(n_1)) 34 | s_2 = Math.sqrt((p_2)*(1-p_2)/(n_2)) 35 | 36 | # Formula for pooled error of the difference of the means: root(π*(1-π)*(1/na+1/nc) 37 | # π = (xa + xc) / (na + nc) 38 | pi = (p_1*n_1 + p_2*n_2)/(n_1 + n_2) 39 | s_p = Math.sqrt(pi*(1-pi)*(1/n_1 + 1/n_2)) 40 | 41 | # Formula for unpooled error of the difference of the means: root(sa**2/na + sc**2/nc) 42 | s_unp = Math.sqrt(s_1**2 + s_2**2) 43 | 44 | # Boolean variable decides whether we can pool our variances 45 | pooled = s_1/s_2 < 2 && s_2/s_1 < 2 46 | 47 | # Assign standard error either the pooled or unpooled variance 48 | se = pooled ? s_p : s_unp 49 | 50 | # Calculate z-score 51 | z_score = (p_1 - p_2)/(se) 52 | 53 | z_score 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/algorithms/block_randomization_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Algorithms::BlockRandomization do 6 | let(:experiment) { Split::Experiment.new "experiment" } 7 | let(:alternative_A) { Split::Alternative.new "A", "experiment" } 8 | let(:alternative_B) { Split::Alternative.new "B", "experiment" } 9 | let(:alternative_C) { Split::Alternative.new "C", "experiment" } 10 | 11 | before :each do 12 | allow(experiment).to receive(:alternatives) { [alternative_A, alternative_B, alternative_C] } 13 | end 14 | 15 | it "should return an alternative" do 16 | expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment).class).to eq(Split::Alternative) 17 | end 18 | 19 | it "should always return the minimum participation option" do 20 | allow(alternative_A).to receive(:participant_count) { 1 } 21 | allow(alternative_B).to receive(:participant_count) { 1 } 22 | allow(alternative_C).to receive(:participant_count) { 0 } 23 | expect(Split::Algorithms::BlockRandomization.choose_alternative(experiment)).to eq(alternative_C) 24 | end 25 | 26 | it "should return one of the minimum participation options when multiple" do 27 | allow(alternative_A).to receive(:participant_count) { 0 } 28 | allow(alternative_B).to receive(:participant_count) { 0 } 29 | allow(alternative_C).to receive(:participant_count) { 0 } 30 | alternative = Split::Algorithms::BlockRandomization.choose_alternative(experiment) 31 | expect([alternative_A, alternative_B, alternative_C].include?(alternative)).to be(true) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/algorithms/weighted_sample_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Algorithms::WeightedSample do 6 | it "should return an alternative" do 7 | experiment = Split::ExperimentCatalog.find_or_create("link_color", { "blue" => 100 }, { "red" => 0 }) 8 | expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).class).to eq(Split::Alternative) 9 | end 10 | 11 | it "should always return a heavily weighted option" do 12 | experiment = Split::ExperimentCatalog.find_or_create("link_color", { "blue" => 100 }, { "red" => 0 }) 13 | expect(Split::Algorithms::WeightedSample.choose_alternative(experiment).name).to eq("blue") 14 | end 15 | 16 | it "should return one of the results" do 17 | experiment = Split::ExperimentCatalog.find_or_create("link_color", { "blue" => 1 }, { "red" => 1 }) 18 | expect(["red", "blue"]).to include Split::Algorithms::WeightedSample.choose_alternative(experiment).name 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/algorithms/whiplash_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Algorithms::Whiplash do 6 | it "should return an algorithm" do 7 | experiment = Split::ExperimentCatalog.find_or_create("link_color", { "blue" => 1 }, { "red" => 1 }) 8 | expect(Split::Algorithms::Whiplash.choose_alternative(experiment).class).to eq(Split::Alternative) 9 | end 10 | 11 | it "should return one of the results" do 12 | experiment = Split::ExperimentCatalog.find_or_create("link_color", { "blue" => 1 }, { "red" => 1 }) 13 | expect(["red", "blue"]).to include Split::Algorithms::Whiplash.choose_alternative(experiment).name 14 | end 15 | 16 | it "should guess floats" do 17 | expect(Split::Algorithms::Whiplash.send(:arm_guess, 0, 0).class).to eq(Float) 18 | expect(Split::Algorithms::Whiplash.send(:arm_guess, 1, 0).class).to eq(Float) 19 | expect(Split::Algorithms::Whiplash.send(:arm_guess, 2, 1).class).to eq(Float) 20 | expect(Split::Algorithms::Whiplash.send(:arm_guess, 1000, 5).class).to eq(Float) 21 | expect(Split::Algorithms::Whiplash.send(:arm_guess, 10, -2).class).to eq(Float) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/alternative_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/alternative" 5 | 6 | describe Split::Alternative do 7 | let(:alternative) { 8 | Split::Alternative.new("Basket", "basket_text") 9 | } 10 | 11 | let(:alternative2) { 12 | Split::Alternative.new("Cart", "basket_text") 13 | } 14 | 15 | let!(:experiment) { 16 | Split::ExperimentCatalog.find_or_create({ "basket_text" => ["purchase", "refund"] }, "Basket", "Cart") 17 | } 18 | 19 | let(:goal1) { "purchase" } 20 | let(:goal2) { "refund" } 21 | 22 | it "should have goals" do 23 | expect(alternative.goals).to eq(["purchase", "refund"]) 24 | end 25 | 26 | it "should have and only return the name" do 27 | expect(alternative.name).to eq("Basket") 28 | end 29 | 30 | describe "weights" do 31 | it "should set the weights" do 32 | experiment = Split::Experiment.new("basket_text", alternatives: [{ "Basket" => 0.6 }, { "Cart" => 0.4 }]) 33 | first = experiment.alternatives[0] 34 | expect(first.name).to eq("Basket") 35 | expect(first.weight).to eq(0.6) 36 | 37 | second = experiment.alternatives[1] 38 | expect(second.name).to eq("Cart") 39 | expect(second.weight).to eq(0.4) 40 | end 41 | 42 | it "accepts probability on alternatives" do 43 | Split.configuration.experiments = { 44 | my_experiment: { 45 | alternatives: [ 46 | { name: "control_opt", percent: 67 }, 47 | { name: "second_opt", percent: 10 }, 48 | { name: "third_opt", percent: 23 }, 49 | ] 50 | } 51 | } 52 | experiment = Split::Experiment.new(:my_experiment) 53 | first = experiment.alternatives[0] 54 | expect(first.name).to eq("control_opt") 55 | expect(first.weight).to eq(0.67) 56 | 57 | second = experiment.alternatives[1] 58 | expect(second.name).to eq("second_opt") 59 | expect(second.weight).to eq(0.1) 60 | end 61 | 62 | it "accepts probability on some alternatives" do 63 | Split.configuration.experiments = { 64 | my_experiment: { 65 | alternatives: [ 66 | { name: "control_opt", percent: 34 }, 67 | "second_opt", 68 | { name: "third_opt", percent: 23 }, 69 | "fourth_opt", 70 | ], 71 | } 72 | } 73 | experiment = Split::Experiment.new(:my_experiment) 74 | alts = experiment.alternatives 75 | [ 76 | ["control_opt", 0.34], 77 | ["second_opt", 0.215], 78 | ["third_opt", 0.23], 79 | ["fourth_opt", 0.215] 80 | ].each do |h| 81 | name, weight = h 82 | alt = alts.shift 83 | expect(alt.name).to eq(name) 84 | expect(alt.weight).to eq(weight) 85 | end 86 | end 87 | # 88 | it "allows name param without probability" do 89 | Split.configuration.experiments = { 90 | my_experiment: { 91 | alternatives: [ 92 | { name: "control_opt" }, 93 | "second_opt", 94 | { name: "third_opt", percent: 64 }, 95 | ], 96 | } 97 | } 98 | experiment = Split::Experiment.new(:my_experiment) 99 | alts = experiment.alternatives 100 | [ 101 | ["control_opt", 0.18], 102 | ["second_opt", 0.18], 103 | ["third_opt", 0.64], 104 | ].each do |h| 105 | name, weight = h 106 | alt = alts.shift 107 | expect(alt.name).to eq(name) 108 | expect(alt.weight).to eq(weight) 109 | end 110 | end 111 | end 112 | 113 | it "should have a default participation count of 0" do 114 | expect(alternative.participant_count).to eq(0) 115 | end 116 | 117 | it "should have a default completed count of 0 for each goal" do 118 | expect(alternative.completed_count).to eq(0) 119 | expect(alternative.completed_count(goal1)).to eq(0) 120 | expect(alternative.completed_count(goal2)).to eq(0) 121 | end 122 | 123 | it "should belong to an experiment" do 124 | expect(alternative.experiment.name).to eq(experiment.name) 125 | end 126 | 127 | it "should save to redis" do 128 | alternative.save 129 | expect(Split.redis.exists?("basket_text:Basket")).to be true 130 | end 131 | 132 | it "should increment participation count" do 133 | old_participant_count = alternative.participant_count 134 | alternative.increment_participation 135 | expect(alternative.participant_count).to eq(old_participant_count+1) 136 | end 137 | 138 | it "should increment completed count for each goal" do 139 | old_default_completed_count = alternative.completed_count 140 | old_completed_count_for_goal1 = alternative.completed_count(goal1) 141 | old_completed_count_for_goal2 = alternative.completed_count(goal2) 142 | 143 | alternative.increment_completion 144 | alternative.increment_completion(goal1) 145 | alternative.increment_completion(goal2) 146 | 147 | expect(alternative.completed_count).to eq(old_default_completed_count+1) 148 | expect(alternative.completed_count(goal1)).to eq(old_completed_count_for_goal1+1) 149 | expect(alternative.completed_count(goal2)).to eq(old_completed_count_for_goal2+1) 150 | end 151 | 152 | it "can be reset" do 153 | alternative.participant_count = 10 154 | alternative.set_completed_count(4, goal1) 155 | alternative.set_completed_count(5, goal2) 156 | alternative.set_completed_count(6) 157 | alternative.reset 158 | expect(alternative.participant_count).to eq(0) 159 | expect(alternative.completed_count(goal1)).to eq(0) 160 | expect(alternative.completed_count(goal2)).to eq(0) 161 | expect(alternative.completed_count).to eq(0) 162 | end 163 | 164 | it "should know if it is the control of an experiment" do 165 | expect(alternative.control?).to be_truthy 166 | expect(alternative2.control?).to be_falsey 167 | end 168 | 169 | describe "unfinished_count" do 170 | it "should be difference between participant and completed counts" do 171 | alternative.increment_participation 172 | expect(alternative.unfinished_count).to eq(alternative.participant_count) 173 | end 174 | 175 | it "should return the correct unfinished_count" do 176 | alternative.participant_count = 10 177 | alternative.set_completed_count(4, goal1) 178 | alternative.set_completed_count(3, goal2) 179 | alternative.set_completed_count(2) 180 | 181 | expect(alternative.unfinished_count).to eq(1) 182 | end 183 | end 184 | 185 | describe "conversion rate" do 186 | it "should be 0 if there are no conversions" do 187 | expect(alternative.completed_count).to eq(0) 188 | expect(alternative.conversion_rate).to eq(0) 189 | end 190 | 191 | it "calculate conversion rate" do 192 | expect(alternative).to receive(:participant_count).exactly(6).times.and_return(10) 193 | expect(alternative).to receive(:completed_count).and_return(4) 194 | expect(alternative.conversion_rate).to eq(0.4) 195 | 196 | expect(alternative).to receive(:completed_count).with(goal1).and_return(5) 197 | expect(alternative.conversion_rate(goal1)).to eq(0.5) 198 | 199 | expect(alternative).to receive(:completed_count).with(goal2).and_return(6) 200 | expect(alternative.conversion_rate(goal2)).to eq(0.6) 201 | end 202 | end 203 | 204 | describe "probability winner" do 205 | before do 206 | experiment.calc_winning_alternatives 207 | end 208 | 209 | it "should have a probability of being the winning alternative (p_winner)" do 210 | expect(alternative.p_winner).not_to be_nil 211 | end 212 | 213 | it "should have a probability of being the winner for each goal" do 214 | expect(alternative.p_winner(goal1)).not_to be_nil 215 | end 216 | 217 | it "should be possible to set the p_winner" do 218 | alternative.set_p_winner(0.5) 219 | expect(alternative.p_winner).to eq(0.5) 220 | end 221 | 222 | it "should be possible to set the p_winner for each goal" do 223 | alternative.set_p_winner(0.5, goal1) 224 | expect(alternative.p_winner(goal1)).to eq(0.5) 225 | end 226 | end 227 | 228 | describe "z score" do 229 | it "should return an error string when the control has 0 people" do 230 | expect(alternative2.z_score).to eq("Needs 30+ participants.") 231 | expect(alternative2.z_score(goal1)).to eq("Needs 30+ participants.") 232 | expect(alternative2.z_score(goal2)).to eq("Needs 30+ participants.") 233 | end 234 | 235 | it "should return an error string when the data is skewed or incomplete as per the np > 5 test" do 236 | control = experiment.control 237 | control.participant_count = 100 238 | control.set_completed_count(50) 239 | 240 | alternative2.participant_count = 50 241 | alternative2.set_completed_count(1) 242 | 243 | expect(alternative2.z_score).to eq("Needs 5+ conversions.") 244 | end 245 | 246 | it "should return a float for a z_score given proper data" do 247 | control = experiment.control 248 | control.participant_count = 120 249 | control.set_completed_count(20) 250 | 251 | alternative2.participant_count = 100 252 | alternative2.set_completed_count(25) 253 | 254 | expect(alternative2.z_score).to be_kind_of(Float) 255 | expect(alternative2.z_score).to_not eq(0) 256 | end 257 | 258 | it "should correctly calculate a z_score given proper data" do 259 | control = experiment.control 260 | control.participant_count = 126 261 | control.set_completed_count(89) 262 | 263 | alternative2.participant_count = 142 264 | alternative2.set_completed_count(119) 265 | 266 | expect(alternative2.z_score.round(2)).to eq(2.58) 267 | end 268 | 269 | it "should be N/A for the control" do 270 | control = experiment.control 271 | expect(control.z_score).to eq("N/A") 272 | expect(control.z_score(goal1)).to eq("N/A") 273 | expect(control.z_score(goal2)).to eq("N/A") 274 | end 275 | 276 | it "should not blow up for Conversion Rates > 1" do 277 | control = experiment.control 278 | control.participant_count = 3474 279 | control.set_completed_count(4244) 280 | 281 | alternative2.participant_count = 3434 282 | alternative2.set_completed_count(4358) 283 | 284 | expect { control.z_score }.not_to raise_error 285 | expect { alternative2.z_score }.not_to raise_error 286 | end 287 | end 288 | 289 | describe "extra_info" do 290 | it "reads saved value of recorded_info in redis" do 291 | saved_recorded_info = { "key_1" => 1, "key_2" => "2" } 292 | Split.redis.hset "#{alternative.experiment_name}:#{alternative.name}", "recorded_info", saved_recorded_info.to_json 293 | extra_info = alternative.extra_info 294 | 295 | expect(extra_info).to eql(saved_recorded_info) 296 | end 297 | end 298 | 299 | describe "record_extra_info" do 300 | it "saves key" do 301 | alternative.record_extra_info("signup", 1) 302 | expect(alternative.extra_info["signup"]).to eql(1) 303 | end 304 | 305 | it "adds value to saved key's value second argument is number" do 306 | alternative.record_extra_info("signup", 1) 307 | alternative.record_extra_info("signup", 2) 308 | expect(alternative.extra_info["signup"]).to eql(3) 309 | end 310 | 311 | it "sets saved's key value to the second argument if it's a string" do 312 | alternative.record_extra_info("signup", "Value 1") 313 | expect(alternative.extra_info["signup"]).to eql("Value 1") 314 | 315 | alternative.record_extra_info("signup", "Value 2") 316 | expect(alternative.extra_info["signup"]).to eql("Value 2") 317 | end 318 | end 319 | end 320 | -------------------------------------------------------------------------------- /spec/cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Cache do 6 | let(:namespace) { :test_namespace } 7 | let(:key) { :test_key } 8 | let(:now) { 1606189017 } 9 | 10 | before { allow(Time).to receive(:now).and_return(now) } 11 | 12 | describe "clear" do 13 | before { Split.configuration.cache = true } 14 | 15 | it "clears the cache" do 16 | expect(Time).to receive(:now).and_return(now).exactly(2).times 17 | Split::Cache.fetch(namespace, key) { Time.now } 18 | Split::Cache.clear 19 | Split::Cache.fetch(namespace, key) { Time.now } 20 | end 21 | end 22 | 23 | describe "clear_key" do 24 | before { Split.configuration.cache = true } 25 | 26 | it "clears the cache" do 27 | expect(Time).to receive(:now).and_return(now).exactly(3).times 28 | Split::Cache.fetch(namespace, :key1) { Time.now } 29 | Split::Cache.fetch(namespace, :key2) { Time.now } 30 | Split::Cache.clear_key(:key1) 31 | 32 | Split::Cache.fetch(namespace, :key1) { Time.now } 33 | Split::Cache.fetch(namespace, :key2) { Time.now } 34 | end 35 | end 36 | 37 | describe "fetch" do 38 | subject { Split::Cache.fetch(namespace, key) { Time.now } } 39 | 40 | context "when cache disabled" do 41 | before { Split.configuration.cache = false } 42 | 43 | it "returns the yield" do 44 | expect(subject).to eql(now) 45 | end 46 | 47 | it "yields every time" do 48 | expect(Time).to receive(:now).and_return(now).exactly(2).times 49 | Split::Cache.fetch(namespace, key) { Time.now } 50 | Split::Cache.fetch(namespace, key) { Time.now } 51 | end 52 | end 53 | 54 | context "when cache enabled" do 55 | before { Split.configuration.cache = true } 56 | 57 | it "returns the yield" do 58 | expect(subject).to eql(now) 59 | end 60 | 61 | it "yields once" do 62 | expect(Time).to receive(:now).and_return(now).once 63 | Split::Cache.fetch(namespace, key) { Time.now } 64 | Split::Cache.fetch(namespace, key) { Time.now } 65 | end 66 | 67 | it "honors namespace" do 68 | expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a) 69 | expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b) 70 | 71 | expect(Split::Cache.fetch(:a, key) { :a }).to eql(:a) 72 | expect(Split::Cache.fetch(:b, key) { :b }).to eql(:b) 73 | end 74 | 75 | it "honors key" do 76 | expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a) 77 | expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b) 78 | 79 | expect(Split::Cache.fetch(namespace, :a) { :a }).to eql(:a) 80 | expect(Split::Cache.fetch(namespace, :b) { :b }).to eql(:b) 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/combined_experiments_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/combined_experiments_helper" 5 | 6 | describe Split::CombinedExperimentsHelper do 7 | include Split::CombinedExperimentsHelper 8 | 9 | describe "ab_combined_test" do 10 | let!(:config_enabled) { true } 11 | let!(:combined_experiments) { [:exp_1_click, :exp_1_scroll ] } 12 | let!(:allow_multiple_experiments) { true } 13 | 14 | before do 15 | Split.configuration.experiments = { 16 | combined_exp_1: { 17 | alternatives: [ { "control"=> 0.5 }, { "test-alt"=> 0.5 } ], 18 | metric: :my_metric, 19 | combined_experiments: combined_experiments 20 | } 21 | } 22 | Split.configuration.enabled = config_enabled 23 | Split.configuration.allow_multiple_experiments = allow_multiple_experiments 24 | end 25 | 26 | context "without config enabled" do 27 | let!(:config_enabled) { false } 28 | 29 | it "raises an error" do 30 | expect { ab_combined_test :combined_exp_1 }.to raise_error(Split::InvalidExperimentsFormatError) 31 | end 32 | end 33 | 34 | context "multiple experiments disabled" do 35 | let!(:allow_multiple_experiments) { false } 36 | 37 | it "raises an error if multiple experiments is disabled" do 38 | expect { ab_combined_test :combined_exp_1 }.to raise_error(Split::InvalidExperimentsFormatError) 39 | end 40 | end 41 | 42 | context "without combined experiments" do 43 | let!(:combined_experiments) { nil } 44 | 45 | it "raises an error" do 46 | expect { ab_combined_test :combined_exp_1 }.to raise_error(Split::InvalidExperimentsFormatError) 47 | end 48 | end 49 | 50 | it "uses same alternative for all sub experiments and returns the alternative" do 51 | allow(self).to receive(:get_alternative) { "test-alt" } 52 | expect(self).to receive(:ab_test).with(:exp_1_click, { "control"=>0.5 }, { "test-alt"=>0.5 }) { "test-alt" } 53 | expect(self).to receive(:ab_test).with(:exp_1_scroll, [{ "control" => 0, "test-alt" => 1 }]) 54 | 55 | expect(ab_combined_test("combined_exp_1")).to eq("test-alt") 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Configuration do 6 | before(:each) { @config = Split::Configuration.new } 7 | 8 | it "should provide a default value for ignore_ip_addresses" do 9 | expect(@config.ignore_ip_addresses).to eq([]) 10 | end 11 | 12 | it "should provide default values for db failover" do 13 | expect(@config.db_failover).to be_falsey 14 | expect(@config.db_failover_on_db_error).to be_a Proc 15 | end 16 | 17 | it "should not allow multiple experiments by default" do 18 | expect(@config.allow_multiple_experiments).to be_falsey 19 | end 20 | 21 | it "should be enabled by default" do 22 | expect(@config.enabled).to be_truthy 23 | end 24 | 25 | it "disabled is the opposite of enabled" do 26 | @config.enabled = false 27 | expect(@config.disabled?).to be_truthy 28 | end 29 | 30 | it "should not store the overridden test group per default" do 31 | expect(@config.store_override).to be_falsey 32 | end 33 | 34 | it "should provide a default pattern for robots" do 35 | %w[Baidu Gigabot Googlebot libwww-perl lwp-trivial msnbot SiteUptime Slurp WordPress ZIBB ZyBorg YandexBot AdsBot-Google Wget curl bitlybot facebookexternalhit spider].each do |robot| 36 | expect(@config.robot_regex).to match(robot) 37 | end 38 | 39 | expect(@config.robot_regex).to match("EventMachine HttpClient") 40 | expect(@config.robot_regex).to match("libwww-perl/5.836") 41 | expect(@config.robot_regex).to match("Pingdom.com_bot_version_1.4_(http://www.pingdom.com)") 42 | 43 | expect(@config.robot_regex).to match(" - ") 44 | end 45 | 46 | it "should accept real UAs with the robot regexp" do 47 | expect(@config.robot_regex).not_to match("Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.9.1.4) Gecko/20091017 SeaMonkey/2.0") 48 | expect(@config.robot_regex).not_to match("Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; F-6.0SP2-20041109; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022; .NET CLR 1.1.4322; InfoPath.3)") 49 | end 50 | 51 | it "should allow adding a bot to the bot list" do 52 | @config.bots["newbot"] = "An amazing test bot" 53 | expect(@config.robot_regex).to match("newbot") 54 | end 55 | 56 | it "should use the session adapter for persistence by default" do 57 | expect(@config.persistence).to eq(Split::Persistence::SessionAdapter) 58 | end 59 | 60 | it "should load a metric" do 61 | @config.experiments = { my_experiment: { alternatives: ["control_opt", "other_opt"], metric: :my_metric } } 62 | 63 | expect(@config.metrics).not_to be_nil 64 | expect(@config.metrics.keys).to eq([:my_metric]) 65 | end 66 | 67 | it "should allow loading of experiment using experment_for" do 68 | @config.experiments = { my_experiment: { alternatives: ["control_opt", "other_opt"], metric: :my_metric } } 69 | expect(@config.experiment_for(:my_experiment)).to eq({ alternatives: ["control_opt", ["other_opt"]] }) 70 | end 71 | 72 | context "when experiments are defined via YAML" do 73 | context "as strings" do 74 | context "in a basic configuration" do 75 | before do 76 | experiments_yaml = <<-eos 77 | my_experiment: 78 | alternatives: 79 | - Control Opt 80 | - Alt One 81 | - Alt Two 82 | resettable: false 83 | eos 84 | @config.experiments = YAML.load(experiments_yaml) 85 | end 86 | 87 | it "should normalize experiments" do 88 | expect(@config.normalized_experiments).to eq({ my_experiment: { resettable: false, alternatives: ["Control Opt", ["Alt One", "Alt Two"]] } }) 89 | end 90 | end 91 | 92 | context "in a configuration with metadata" do 93 | before do 94 | experiments_yaml = <<-eos 95 | my_experiment: 96 | alternatives: 97 | - name: Control Opt 98 | percent: 67 99 | - name: Alt One 100 | percent: 10 101 | - name: Alt Two 102 | percent: 23 103 | metadata: 104 | Control Opt: 105 | text: 'Control Option' 106 | Alt One: 107 | text: 'Alternative One' 108 | Alt Two: 109 | text: 'Alternative Two' 110 | resettable: false 111 | eos 112 | @config.experiments = YAML.load(experiments_yaml) 113 | end 114 | 115 | it "should have metadata on the experiment" do 116 | meta = @config.normalized_experiments[:my_experiment][:metadata] 117 | expect(meta).to_not be nil 118 | expect(meta["Control Opt"]["text"]).to eq("Control Option") 119 | end 120 | end 121 | 122 | context "in a complex configuration" do 123 | before do 124 | experiments_yaml = <<-eos 125 | my_experiment: 126 | alternatives: 127 | - name: Control Opt 128 | percent: 67 129 | - name: Alt One 130 | percent: 10 131 | - name: Alt Two 132 | percent: 23 133 | resettable: false 134 | metric: my_metric 135 | another_experiment: 136 | alternatives: 137 | - a 138 | - b 139 | eos 140 | @config.experiments = YAML.load(experiments_yaml) 141 | end 142 | 143 | it "should normalize experiments" do 144 | expect(@config.normalized_experiments).to eq({ my_experiment: { resettable: false, alternatives: [{ "Control Opt"=>0.67 }, 145 | [{ "Alt One"=>0.1 }, { "Alt Two"=>0.23 }]] }, another_experiment: { alternatives: ["a", ["b"]] } }) 146 | end 147 | 148 | it "should recognize metrics" do 149 | expect(@config.metrics).not_to be_nil 150 | expect(@config.metrics.keys).to eq([:my_metric]) 151 | end 152 | end 153 | end 154 | 155 | context "as symbols" do 156 | context "with valid YAML" do 157 | before do 158 | experiments_yaml = <<-eos 159 | :my_experiment: 160 | :alternatives: 161 | - Control Opt 162 | - Alt One 163 | - Alt Two 164 | :resettable: false 165 | eos 166 | @config.experiments = YAML.load(experiments_yaml) 167 | end 168 | 169 | it "should normalize experiments" do 170 | expect(@config.normalized_experiments).to eq({ my_experiment: { resettable: false, alternatives: ["Control Opt", ["Alt One", "Alt Two"]] } }) 171 | end 172 | end 173 | 174 | context "with invalid YAML" do 175 | let(:yaml) { YAML.load(input) } 176 | 177 | context "with an empty string" do 178 | let(:input) { "" } 179 | 180 | it "should raise an error" do 181 | expect { @config.experiments = yaml }.to raise_error(Split::InvalidExperimentsFormatError) 182 | end 183 | end 184 | 185 | context "with just the YAML header" do 186 | let(:input) { "---" } 187 | 188 | it "should raise an error" do 189 | expect { @config.experiments = yaml }.to raise_error(Split::InvalidExperimentsFormatError) 190 | end 191 | end 192 | end 193 | end 194 | end 195 | 196 | it "should normalize experiments" do 197 | @config.experiments = { 198 | my_experiment: { 199 | alternatives: [ 200 | { name: "control_opt", percent: 67 }, 201 | { name: "second_opt", percent: 10 }, 202 | { name: "third_opt", percent: 23 }, 203 | ], 204 | } 205 | } 206 | 207 | expect(@config.normalized_experiments).to eq({ my_experiment: { alternatives: [{ "control_opt"=>0.67 }, [{ "second_opt"=>0.1 }, { "third_opt"=>0.23 }]] } }) 208 | end 209 | 210 | context "redis configuration" do 211 | it "should default to local redis server" do 212 | old_redis_url = ENV["REDIS_URL"] 213 | ENV.delete("REDIS_URL") 214 | expect(Split::Configuration.new.redis).to eq("redis://localhost:6379") 215 | ENV["REDIS_URL"] = old_redis_url 216 | end 217 | 218 | it "should allow for redis url to be configured" do 219 | @config.redis = "custom_redis_url" 220 | expect(@config.redis).to eq("custom_redis_url") 221 | end 222 | 223 | context "provided REDIS_URL environment variable" do 224 | it "should use the ENV variable" do 225 | old_redis_url = ENV["REDIS_URL"] 226 | ENV["REDIS_URL"] = "env_redis_url" 227 | expect(Split::Configuration.new.redis).to eq("env_redis_url") 228 | ENV["REDIS_URL"] = old_redis_url 229 | end 230 | end 231 | end 232 | 233 | context "persistence cookie length" do 234 | it "should default to 1 year" do 235 | expect(@config.persistence_cookie_length).to eq(31536000) 236 | end 237 | 238 | it "should allow the persistence cookie length to be configured" do 239 | @config.persistence_cookie_length = 2592000 240 | expect(@config.persistence_cookie_length).to eq(2592000) 241 | end 242 | end 243 | 244 | context "persistence cookie domain" do 245 | it "should default to nil" do 246 | expect(@config.persistence_cookie_domain).to eq(nil) 247 | end 248 | 249 | it "should allow the persistence cookie domain to be configured" do 250 | @config.persistence_cookie_domain = ".acme.com" 251 | expect(@config.persistence_cookie_domain).to eq(".acme.com") 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/dashboard/pagination_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/dashboard/pagination_helpers" 5 | 6 | describe Split::DashboardPaginationHelpers do 7 | include Split::DashboardPaginationHelpers 8 | 9 | let(:url) { "/split/" } 10 | 11 | describe "#pagination_per" do 12 | context "when params empty" do 13 | let(:params) { Hash[] } 14 | 15 | it "returns the default (10)" do 16 | default_per_page = Split.configuration.dashboard_pagination_default_per_page 17 | expect(pagination_per).to eql default_per_page 18 | expect(pagination_per).to eql 10 19 | end 20 | end 21 | 22 | context "when params[:per] is 5" do 23 | let(:params) { Hash[per: 5] } 24 | 25 | it "returns 5" do 26 | expect(pagination_per).to eql 5 27 | end 28 | end 29 | end 30 | 31 | describe "#page_number" do 32 | context "when params empty" do 33 | let(:params) { Hash[] } 34 | 35 | it "returns 1" do 36 | expect(page_number).to eql 1 37 | end 38 | end 39 | 40 | context 'when params[:page] is "2"' do 41 | let(:params) { Hash[page: "2"] } 42 | 43 | it "returns 2" do 44 | expect(page_number).to eql 2 45 | end 46 | end 47 | end 48 | 49 | describe "#paginated" do 50 | let(:collection) { (1..20).to_a } 51 | let(:params) { Hash[per: "5", page: "3"] } 52 | 53 | it { expect(paginated(collection)).to eql [11, 12, 13, 14, 15] } 54 | end 55 | 56 | describe "#show_first_page_tag?" do 57 | context "when page is 1" do 58 | it { expect(show_first_page_tag?).to be false } 59 | end 60 | 61 | context "when page is 3" do 62 | let(:params) { Hash[page: "3"] } 63 | it { expect(show_first_page_tag?).to be true } 64 | end 65 | end 66 | 67 | describe "#first_page_tag" do 68 | it { expect(first_page_tag).to eql '1' } 69 | end 70 | 71 | describe "#show_first_ellipsis_tag?" do 72 | context "when page is 1" do 73 | it { expect(show_first_ellipsis_tag?).to be false } 74 | end 75 | 76 | context "when page is 4" do 77 | let(:params) { Hash[page: "4"] } 78 | it { expect(show_first_ellipsis_tag?).to be true } 79 | end 80 | end 81 | 82 | describe "#ellipsis_tag" do 83 | it { expect(ellipsis_tag).to eql "..." } 84 | end 85 | 86 | describe "#show_prev_page_tag?" do 87 | context "when page is 1" do 88 | it { expect(show_prev_page_tag?).to be false } 89 | end 90 | 91 | context "when page is 2" do 92 | let(:params) { Hash[page: "2"] } 93 | it { expect(show_prev_page_tag?).to be true } 94 | end 95 | end 96 | 97 | describe "#prev_page_tag" do 98 | context "when page is 2" do 99 | let(:params) { Hash[page: "2"] } 100 | 101 | it do 102 | expect(prev_page_tag).to eql '1' 103 | end 104 | end 105 | 106 | context "when page is 3" do 107 | let(:params) { Hash[page: "3"] } 108 | 109 | it do 110 | expect(prev_page_tag).to eql '2' 111 | end 112 | end 113 | end 114 | 115 | describe "#show_prev_page_tag?" do 116 | context "when page is 1" do 117 | it { expect(show_prev_page_tag?).to be false } 118 | end 119 | 120 | context "when page is 2" do 121 | let(:params) { Hash[page: "2"] } 122 | it { expect(show_prev_page_tag?).to be true } 123 | end 124 | end 125 | 126 | describe "#current_page_tag" do 127 | context "when page is 1" do 128 | let(:params) { Hash[page: "1"] } 129 | it { expect(current_page_tag).to eql "1" } 130 | end 131 | 132 | context "when page is 2" do 133 | let(:params) { Hash[page: "2"] } 134 | it { expect(current_page_tag).to eql "2" } 135 | end 136 | end 137 | 138 | describe "#show_next_page_tag?" do 139 | context "when page is 2" do 140 | let(:params) { Hash[page: "2"] } 141 | 142 | context "when collection length is 20" do 143 | let(:collection) { (1..20).to_a } 144 | it { expect(show_next_page_tag?(collection)).to be false } 145 | end 146 | 147 | context "when collection length is 25" do 148 | let(:collection) { (1..25).to_a } 149 | it { expect(show_next_page_tag?(collection)).to be true } 150 | end 151 | end 152 | end 153 | 154 | describe "#next_page_tag" do 155 | context "when page is 1" do 156 | let(:params) { Hash[page: "1"] } 157 | it { expect(next_page_tag).to eql '2' } 158 | end 159 | 160 | context "when page is 2" do 161 | let(:params) { Hash[page: "2"] } 162 | it { expect(next_page_tag).to eql '3' } 163 | end 164 | end 165 | 166 | describe "#total_pages" do 167 | context "when collection length is 30" do 168 | let(:collection) { (1..30).to_a } 169 | it { expect(total_pages(collection)).to eql 3 } 170 | end 171 | 172 | context "when collection length is 35" do 173 | let(:collection) { (1..35).to_a } 174 | it { expect(total_pages(collection)).to eql 4 } 175 | end 176 | end 177 | 178 | describe "#show_last_ellipsis_tag?" do 179 | let(:collection) { (1..30).to_a } 180 | let(:params) { Hash[per: "5", page: "2"] } 181 | it { expect(show_last_ellipsis_tag?(collection)).to be true } 182 | end 183 | 184 | describe "#show_last_page_tag?" do 185 | let(:collection) { (1..30).to_a } 186 | 187 | context "when page is 5/6" do 188 | let(:params) { Hash[per: "5", page: "5"] } 189 | it { expect(show_last_page_tag?(collection)).to be false } 190 | end 191 | 192 | context "when page is 4/6" do 193 | let(:params) { Hash[per: "5", page: "4"] } 194 | it { expect(show_last_page_tag?(collection)).to be true } 195 | end 196 | end 197 | 198 | describe "#last_page_tag" do 199 | let(:collection) { (1..30).to_a } 200 | it { expect(last_page_tag(collection)).to eql '3' } 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /spec/dashboard/paginator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/dashboard/paginator" 5 | 6 | describe Split::DashboardPaginator do 7 | context "when collection is 1..20" do 8 | let(:collection) { (1..20).to_a } 9 | 10 | context "when per 5 for page" do 11 | let(:per) { 5 } 12 | 13 | it "when page number is 1 result is [1, 2, 3, 4, 5]" do 14 | result = Split::DashboardPaginator.new(collection, 1, per).paginate 15 | expect(result).to eql [1, 2, 3, 4, 5] 16 | end 17 | 18 | it "when page number is 2 result is [6, 7, 8, 9, 10]" do 19 | result = Split::DashboardPaginator.new(collection, 2, per).paginate 20 | expect(result).to eql [6, 7, 8, 9, 10] 21 | end 22 | end 23 | 24 | context "when per 10 for page" do 25 | let(:per) { 10 } 26 | 27 | it "when page number is 1 result is [1..10]" do 28 | result = Split::DashboardPaginator.new(collection, 1, per).paginate 29 | expect(result).to eql [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 30 | end 31 | 32 | it "when page number is 2 result is [10..20]" do 33 | result = Split::DashboardPaginator.new(collection, 2, per).paginate 34 | expect(result).to eql [11, 12, 13, 14, 15, 16, 17, 18, 19, 20] 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/dashboard_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/dashboard/helpers" 5 | 6 | include Split::DashboardHelpers 7 | 8 | describe Split::DashboardHelpers do 9 | describe "confidence_level" do 10 | it "should handle very small numbers" do 11 | expect(confidence_level(Complex(2e-18, -0.03))).to eq("Insufficient confidence") 12 | end 13 | 14 | it "should consider a z-score of 1.65 <= z < 1.96 as 90% confident" do 15 | expect(confidence_level(1.65)).to eq("90% confidence") 16 | expect(confidence_level(1.80)).to eq("90% confidence") 17 | end 18 | 19 | it "should consider a z-score of 1.96 <= z < 2.58 as 95% confident" do 20 | expect(confidence_level(1.96)).to eq("95% confidence") 21 | expect(confidence_level(2.00)).to eq("95% confidence") 22 | end 23 | 24 | it "should consider a z-score of z >= 2.58 as 99% confident" do 25 | expect(confidence_level(2.58)).to eq("99% confidence") 26 | expect(confidence_level(3.00)).to eq("99% confidence") 27 | end 28 | 29 | describe "#round" do 30 | it "can round number strings" do 31 | expect(round("3.1415")).to eq BigDecimal("3.14") 32 | end 33 | 34 | it "can round number strings for precsion" do 35 | expect(round("3.1415", 1)).to eq BigDecimal("3.1") 36 | end 37 | 38 | it "can handle invalid number strings" do 39 | expect(round("N/A")).to be_zero 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/dashboard_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "rack/test" 5 | require "split/dashboard" 6 | 7 | describe Split::Dashboard do 8 | include Rack::Test::Methods 9 | 10 | class TestDashboard < Split::Dashboard 11 | include Split::Helper 12 | 13 | get "/my_experiment" do 14 | ab_test(params[:experiment], "blue", "red") 15 | end 16 | end 17 | 18 | def app 19 | @app ||= TestDashboard 20 | end 21 | 22 | def link(color) 23 | Split::Alternative.new(color, experiment.name) 24 | end 25 | 26 | let(:experiment) { 27 | Split::ExperimentCatalog.find_or_create("link_color", "blue", "red") 28 | } 29 | 30 | let(:experiment_with_goals) { 31 | Split::ExperimentCatalog.find_or_create({ "link_color" => ["goal_1", "goal_2"] }, "blue", "red") 32 | } 33 | 34 | let(:metric) { 35 | Split::Metric.find_or_create(name: "testmetric", experiments: [experiment, experiment_with_goals]) 36 | } 37 | 38 | let(:red_link) { link("red") } 39 | let(:blue_link) { link("blue") } 40 | 41 | before(:each) do 42 | Split.configuration.beta_probability_simulations = 1 43 | end 44 | 45 | it "should respond to /" do 46 | get "/" 47 | expect(last_response).to be_ok 48 | end 49 | 50 | context "start experiment manually" do 51 | before do 52 | Split.configuration.start_manually = true 53 | end 54 | 55 | context "experiment without goals" do 56 | it "should display a Start button" do 57 | experiment 58 | get "/" 59 | expect(last_response.body).to include("Start") 60 | 61 | post "/start?experiment=#{experiment.name}" 62 | get "/" 63 | expect(last_response.body).to include("Reset Data") 64 | expect(last_response.body).not_to include("Metrics:") 65 | end 66 | end 67 | 68 | context "experiment with metrics" do 69 | it "should display the names of associated metrics" do 70 | metric 71 | get "/" 72 | expect(last_response.body).to include("Metrics:testmetric") 73 | end 74 | end 75 | 76 | context "with goals" do 77 | it "should display a Start button" do 78 | experiment_with_goals 79 | get "/" 80 | expect(last_response.body).to include("Start") 81 | 82 | post "/start?experiment=#{experiment.name}" 83 | get "/" 84 | expect(last_response.body).to include("Reset Data") 85 | end 86 | end 87 | end 88 | 89 | describe "force alternative" do 90 | context "initial version" do 91 | let!(:user) do 92 | Split::User.new(@app, { experiment.name => "red" }) 93 | end 94 | 95 | before do 96 | allow(Split::User).to receive(:new).and_return(user) 97 | end 98 | 99 | it "should set current user's alternative" do 100 | blue_link.participant_count = 7 101 | post "/force_alternative?experiment=#{experiment.name}", alternative: "blue" 102 | 103 | get "/my_experiment?experiment=#{experiment.name}" 104 | expect(last_response.body).to include("blue") 105 | end 106 | 107 | it "should not modify an existing user" do 108 | blue_link.participant_count = 7 109 | post "/force_alternative?experiment=#{experiment.name}", alternative: "blue" 110 | 111 | expect(user[experiment.key]).to eq("red") 112 | expect(blue_link.participant_count).to eq(7) 113 | end 114 | end 115 | 116 | context "incremented version" do 117 | let!(:user) do 118 | experiment.increment_version 119 | Split::User.new(@app, { "#{experiment.name}:#{experiment.version}" => "red" }) 120 | end 121 | 122 | before do 123 | allow(Split::User).to receive(:new).and_return(user) 124 | end 125 | 126 | it "should set current user's alternative" do 127 | blue_link.participant_count = 7 128 | post "/force_alternative?experiment=#{experiment.name}", alternative: "blue" 129 | 130 | get "/my_experiment?experiment=#{experiment.name}" 131 | expect(last_response.body).to include("blue") 132 | end 133 | end 134 | end 135 | 136 | describe "index page" do 137 | context "with winner" do 138 | before { experiment.winner = "red" } 139 | 140 | it "displays `Reopen Experiment` button" do 141 | get "/" 142 | 143 | expect(last_response.body).to include("Reopen Experiment") 144 | end 145 | end 146 | 147 | context "without winner" do 148 | it "should not display `Reopen Experiment` button" do 149 | get "/" 150 | 151 | expect(last_response.body).to_not include("Reopen Experiment") 152 | end 153 | end 154 | end 155 | 156 | describe "reopen experiment" do 157 | before { experiment.winner = "red" } 158 | 159 | it "redirects" do 160 | post "/reopen?experiment=#{experiment.name}" 161 | 162 | expect(last_response).to be_redirect 163 | end 164 | 165 | it "removes winner" do 166 | post "/reopen?experiment=#{experiment.name}" 167 | 168 | expect(Split::ExperimentCatalog.find(experiment.name)).to_not have_winner 169 | end 170 | 171 | it "keeps existing stats" do 172 | red_link.participant_count = 5 173 | blue_link.participant_count = 7 174 | experiment.winner = "blue" 175 | 176 | post "/reopen?experiment=#{experiment.name}" 177 | 178 | expect(red_link.participant_count).to eq(5) 179 | expect(blue_link.participant_count).to eq(7) 180 | end 181 | end 182 | 183 | describe "update cohorting" do 184 | it "calls enable of cohorting when action is enable" do 185 | post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "enable" } 186 | 187 | expect(experiment.cohorting_disabled?).to eq false 188 | end 189 | 190 | it "calls disable of cohorting when action is disable" do 191 | post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "disable" } 192 | 193 | expect(experiment.cohorting_disabled?).to eq true 194 | end 195 | 196 | it "calls neither enable or disable cohorting when passed invalid action" do 197 | previous_value = experiment.cohorting_disabled? 198 | 199 | post "/update_cohorting?experiment=#{experiment.name}", { "cohorting_action": "other" } 200 | 201 | expect(experiment.cohorting_disabled?).to eq previous_value 202 | end 203 | end 204 | 205 | describe "initialize experiment" do 206 | before do 207 | Split.configuration.experiments = { 208 | my_experiment: { 209 | alternatives: [ "control", "alternative" ], 210 | } 211 | } 212 | end 213 | 214 | it "initializes the experiment when the experiment is given" do 215 | expect(Split::ExperimentCatalog.find("my_experiment")).to be nil 216 | 217 | post "/initialize_experiment", { experiment: "my_experiment" } 218 | 219 | experiment = Split::ExperimentCatalog.find("my_experiment") 220 | expect(experiment).to be_a(Split::Experiment) 221 | end 222 | 223 | it "does not attempt to intialize the experiment when empty experiment is given" do 224 | post "/initialize_experiment", { experiment: "" } 225 | 226 | expect(Split::ExperimentCatalog).to_not receive(:find_or_create) 227 | end 228 | 229 | it "does not attempt to intialize the experiment when no experiment is given" do 230 | post "/initialize_experiment" 231 | 232 | expect(Split::ExperimentCatalog).to_not receive(:find_or_create) 233 | end 234 | end 235 | 236 | it "should reset an experiment" do 237 | red_link.participant_count = 5 238 | blue_link.participant_count = 7 239 | experiment.winner = "blue" 240 | 241 | post "/reset?experiment=#{experiment.name}" 242 | 243 | expect(last_response).to be_redirect 244 | 245 | new_red_count = red_link.participant_count 246 | new_blue_count = blue_link.participant_count 247 | 248 | expect(new_blue_count).to eq(0) 249 | expect(new_red_count).to eq(0) 250 | expect(experiment.winner).to be_nil 251 | end 252 | 253 | it "should delete an experiment" do 254 | delete "/experiment?experiment=#{experiment.name}" 255 | expect(last_response).to be_redirect 256 | expect(Split::ExperimentCatalog.find(experiment.name)).to be_nil 257 | end 258 | 259 | it "should mark an alternative as the winner" do 260 | expect(experiment.winner).to be_nil 261 | post "/experiment?experiment=#{experiment.name}", alternative: "red" 262 | 263 | expect(last_response).to be_redirect 264 | expect(experiment.winner.name).to eq("red") 265 | end 266 | 267 | it "should display the start date" do 268 | experiment.start 269 | 270 | get "/" 271 | 272 | expect(last_response.body).to include("#{experiment.start_time.strftime('%Y-%m-%d')}") 273 | end 274 | 275 | it "should handle experiments without a start date" do 276 | Split.redis.hdel(:experiment_start_times, experiment.name) 277 | 278 | get "/" 279 | 280 | expect(last_response.body).to include("Unknown") 281 | end 282 | 283 | it "should be explode with experiments with invalid data" do 284 | red_link.participant_count = 1 285 | red_link.set_completed_count(10) 286 | 287 | blue_link.participant_count = 3 288 | blue_link.set_completed_count(2) 289 | 290 | get "/" 291 | 292 | expect(last_response).to be_ok 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /spec/encapsulated_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::EncapsulatedHelper do 6 | let(:context_shim) { Split::EncapsulatedHelper::ContextShim.new(double(request: request)) } 7 | 8 | describe "ab_test" do 9 | before do 10 | allow_any_instance_of(Split::EncapsulatedHelper::ContextShim).to receive(:ab_user) 11 | .and_return(mock_user) 12 | end 13 | 14 | it "calls the block with selected alternative" do 15 | expect { |block| context_shim.ab_test("link_color", "red", "red", &block) }.to yield_with_args("red", {}) 16 | end 17 | 18 | context "inside a view" do 19 | it "works inside ERB" do 20 | require "erb" 21 | template = ERB.new(<<-ERB.split(/\s+/s).map(&:strip).join(" "), nil, "%") 22 | foo <% context_shim.ab_test(:foo, '1', '2') do |alt, meta| %> 23 | static <%= alt %> 24 | <% end %> 25 | ERB 26 | expect(template.result(binding)).to match(/foo static \d/) 27 | end 28 | end 29 | end 30 | 31 | describe "context" do 32 | it "is passed in shim" do 33 | ctx = Class.new { 34 | include Split::EncapsulatedHelper 35 | public :session 36 | }.new 37 | 38 | expect(ctx).to receive(:session) { {} } 39 | expect { ctx.ab_test("link_color", "blue", "red") }.not_to raise_error 40 | end 41 | 42 | context "when request is defined in context of ContextShim" do 43 | context "when overriding by params" do 44 | it do 45 | ctx = Class.new { 46 | public :session 47 | def request 48 | build_request(params: { 49 | "ab_test" => { "link_color" => "blue" } 50 | }) 51 | end 52 | }.new 53 | 54 | context_shim = Split::EncapsulatedHelper::ContextShim.new(ctx) 55 | expect(context_shim.ab_test("link_color", "blue", "red")).to be("blue") 56 | end 57 | end 58 | 59 | context "when overriding by cookies" do 60 | it do 61 | ctx = Class.new { 62 | public :session 63 | def request 64 | build_request(cookies: { 65 | "split_override" => '{ "link_color": "red" }' 66 | }) 67 | end 68 | }.new 69 | 70 | context_shim = Split::EncapsulatedHelper::ContextShim.new(ctx) 71 | expect(context_shim.ab_test("link_color", "blue", "red")).to be("red") 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/experiment_catalog_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::ExperimentCatalog do 6 | subject { Split::ExperimentCatalog } 7 | 8 | describe ".find_or_create" do 9 | it "should not raise an error when passed strings for alternatives" do 10 | expect { subject.find_or_create("xyz", "1", "2", "3") }.not_to raise_error 11 | end 12 | 13 | it "should not raise an error when passed an array for alternatives" do 14 | expect { subject.find_or_create("xyz", ["1", "2", "3"]) }.not_to raise_error 15 | end 16 | 17 | it "should raise the appropriate error when passed integers for alternatives" do 18 | expect { subject.find_or_create("xyz", 1, 2, 3) }.to raise_error(ArgumentError) 19 | end 20 | 21 | it "should raise the appropriate error when passed symbols for alternatives" do 22 | expect { subject.find_or_create("xyz", :a, :b, :c) }.to raise_error(ArgumentError) 23 | end 24 | 25 | it "should not raise error when passed an array for goals" do 26 | expect { subject.find_or_create({ "link_color" => ["purchase", "refund"] }, "blue", "red") } 27 | .not_to raise_error 28 | end 29 | 30 | it "should not raise error when passed just one goal" do 31 | expect { subject.find_or_create({ "link_color" => "purchase" }, "blue", "red") } 32 | .not_to raise_error 33 | end 34 | 35 | it "constructs a new experiment" do 36 | expect(subject.find_or_create("my_exp", "control me").control.to_s).to eq("control me") 37 | end 38 | end 39 | 40 | describe ".find" do 41 | it "should return an existing experiment" do 42 | experiment = Split::Experiment.new("basket_text", alternatives: ["blue", "red", "green"]) 43 | experiment.save 44 | 45 | experiment = subject.find("basket_text") 46 | 47 | expect(experiment.name).to eq("basket_text") 48 | end 49 | 50 | it "should return nil if experiment not exist" do 51 | expect(subject.find("non_existent_experiment")).to be_nil 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/goals_collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/goals_collection" 5 | require "time" 6 | 7 | describe Split::GoalsCollection do 8 | let(:experiment_name) { "experiment_name" } 9 | 10 | describe "initialization" do 11 | let(:goals_collection) { 12 | Split::GoalsCollection.new("experiment_name", ["goal1", "goal2"]) 13 | } 14 | 15 | it "should have an experiment_name" do 16 | expect(goals_collection.instance_variable_get(:@experiment_name)). 17 | to eq("experiment_name") 18 | end 19 | 20 | it "should have a list of goals" do 21 | expect(goals_collection.instance_variable_get(:@goals)). 22 | to eq(["goal1", "goal2"]) 23 | end 24 | end 25 | 26 | describe "#validate!" do 27 | it "should't raise ArgumentError if @goals is nil?" do 28 | goals_collection = Split::GoalsCollection.new("experiment_name") 29 | expect { goals_collection.validate! }.not_to raise_error 30 | end 31 | 32 | it "should raise ArgumentError if @goals is not an Array" do 33 | goals_collection = Split::GoalsCollection. 34 | new("experiment_name", "not an array") 35 | expect { goals_collection.validate! }.to raise_error(ArgumentError) 36 | end 37 | 38 | it "should't raise ArgumentError if @goals is an array" do 39 | goals_collection = Split::GoalsCollection. 40 | new("experiment_name", ["an array"]) 41 | expect { goals_collection.validate! }.not_to raise_error 42 | end 43 | end 44 | 45 | describe "#delete" do 46 | let(:goals_key) { "#{experiment_name}:goals" } 47 | 48 | it "should delete goals from redis" do 49 | goals_collection = Split::GoalsCollection.new(experiment_name, ["goal1"]) 50 | goals_collection.save 51 | 52 | goals_collection.delete 53 | expect(Split.redis.exists?(goals_key)).to be false 54 | end 55 | end 56 | 57 | describe "#save" do 58 | let(:goals_key) { "#{experiment_name}:goals" } 59 | 60 | it "should return false if @goals is nil" do 61 | goals_collection = Split::GoalsCollection. 62 | new(experiment_name, nil) 63 | 64 | expect(goals_collection.save).to be false 65 | end 66 | 67 | it "should save goals to redis if @goals is valid" do 68 | goals = ["valid goal 1", "valid goal 2"] 69 | collection = Split::GoalsCollection.new(experiment_name, goals) 70 | collection.save 71 | 72 | expect(Split.redis.lrange(goals_key, 0, -1)).to eq goals 73 | end 74 | 75 | it "should return @goals if @goals is valid" do 76 | goals_collection = Split::GoalsCollection. 77 | new(experiment_name, ["valid goal"]) 78 | 79 | expect(goals_collection.save).to eq(["valid goal"]) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/metric_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/metric" 5 | 6 | describe Split::Metric do 7 | describe "possible experiments" do 8 | it "should load the experiment if there is one, but no metric" do 9 | experiment = Split::ExperimentCatalog.find_or_create("color", "red", "blue") 10 | expect(Split::Metric.possible_experiments("color")).to eq([experiment]) 11 | end 12 | 13 | it "should load the experiments in a metric" do 14 | experiment1 = Split::ExperimentCatalog.find_or_create("color", "red", "blue") 15 | experiment2 = Split::ExperimentCatalog.find_or_create("size", "big", "small") 16 | 17 | metric = Split::Metric.new(name: "purchase", experiments: [experiment1, experiment2]) 18 | metric.save 19 | expect(Split::Metric.possible_experiments("purchase")).to include(experiment1, experiment2) 20 | end 21 | 22 | it "should load both the metric experiments and an experiment with the same name" do 23 | experiment1 = Split::ExperimentCatalog.find_or_create("purchase", "red", "blue") 24 | experiment2 = Split::ExperimentCatalog.find_or_create("size", "big", "small") 25 | 26 | metric = Split::Metric.new(name: "purchase", experiments: [experiment2]) 27 | metric.save 28 | expect(Split::Metric.possible_experiments("purchase")).to include(experiment1, experiment2) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/persistence/cookie_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "rack/test" 5 | 6 | describe Split::Persistence::CookieAdapter do 7 | subject { described_class.new(context) } 8 | 9 | shared_examples "sets cookies correctly" do 10 | describe "#[] and #[]=" do 11 | it "set and return the value for given key" do 12 | subject["my_key"] = "my_value" 13 | expect(subject["my_key"]).to eq("my_value") 14 | end 15 | 16 | it "handles invalid JSON" do 17 | context.request.cookies["split"] = "{\"foo\":2," 18 | 19 | expect(subject["my_key"]).to be_nil 20 | subject["my_key"] = "my_value" 21 | expect(subject["my_key"]).to eq("my_value") 22 | end 23 | 24 | it "ignores valid JSON of invalid type (integer)" do 25 | context.request.cookies["split"] = "2" 26 | 27 | expect(subject["my_key"]).to be_nil 28 | subject["my_key"] = "my_value" 29 | expect(subject["my_key"]).to eq("my_value") 30 | end 31 | 32 | it "ignores valid JSON of invalid type (array)" do 33 | context.request.cookies["split"] = "[\"foo\", \"bar\"]" 34 | 35 | expect(subject["my_key"]).to be_nil 36 | subject["my_key"] = "my_value" 37 | expect(subject["my_key"]).to eq("my_value") 38 | end 39 | end 40 | 41 | describe "#delete" do 42 | it "should delete the given key" do 43 | subject["my_key"] = "my_value" 44 | subject.delete("my_key") 45 | expect(subject["my_key"]).to be_nil 46 | end 47 | end 48 | 49 | describe "#keys" do 50 | it "should return an array of the session's stored keys" do 51 | subject["my_key"] = "my_value" 52 | subject["my_second_key"] = "my_second_value" 53 | expect(subject.keys).to match(["my_key", "my_second_key"]) 54 | end 55 | end 56 | end 57 | 58 | 59 | context "when using Rack" do 60 | let(:env) { Rack::MockRequest.env_for("http://example.com:8080/") } 61 | let(:request) { Rack::Request.new(env) } 62 | let(:response) { Rack::MockResponse.new(200, {}, "") } 63 | let(:context) { double(request: request, response: response, cookies: CookiesMock.new) } 64 | 65 | include_examples "sets cookies correctly" 66 | 67 | it "puts multiple experiments in a single cookie" do 68 | subject["foo"] = "FOO" 69 | subject["bar"] = "BAR" 70 | expect(Array(context.response.headers["Set-Cookie"])).to include(/\Asplit=%7B%22foo%22%3A%22FOO%22%2C%22bar%22%3A%22BAR%22%7D; path=\/; expires=[a-zA-Z]{3}, \d{2} [a-zA-Z]{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}\Z/) 71 | end 72 | 73 | it "ensure other added cookies are not overriden" do 74 | context.response.set_cookie "dummy", "wow" 75 | subject["foo"] = "FOO" 76 | expect(Array(context.response.headers["Set-Cookie"])).to include(/dummy=wow/) 77 | expect(Array(context.response.headers["Set-Cookie"])).to include(/split=/) 78 | end 79 | end 80 | 81 | context "when @context is an ActionController::Base" do 82 | before :context do 83 | require "rails" 84 | require "action_controller/railtie" 85 | end 86 | 87 | let(:context) do 88 | controller = controller_class.new 89 | if controller.respond_to?(:set_request!) 90 | controller.set_request!(ActionDispatch::Request.new({})) 91 | else # Before rails 5.0 92 | controller.send(:"request=", ActionDispatch::Request.new({})) 93 | end 94 | 95 | response = ActionDispatch::Response.new(200, {}, "").tap do |res| 96 | res.request = controller.request 97 | end 98 | 99 | if controller.respond_to?(:set_response!) 100 | controller.set_response!(response) 101 | else # Before rails 5.0 102 | controller.send(:set_response!, response) 103 | end 104 | controller 105 | end 106 | 107 | let(:controller_class) { Class.new(ActionController::Base) } 108 | 109 | include_examples "sets cookies correctly" 110 | 111 | it "puts multiple experiments in a single cookie" do 112 | subject["foo"] = "FOO" 113 | subject["bar"] = "BAR" 114 | expect(subject.keys).to eq(["foo", "bar"]) 115 | expect(subject["foo"]).to eq("FOO") 116 | expect(subject["bar"]).to eq("BAR") 117 | cookie_jar = context.request.env["action_dispatch.cookies"] 118 | expect(cookie_jar["split"]).to eq('{"foo":"FOO","bar":"BAR"}') 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/persistence/dual_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Persistence::DualAdapter do 6 | let(:context) { "some context" } 7 | 8 | let(:logged_in_adapter_instance) { double } 9 | let(:logged_in_adapter) do 10 | Class.new.tap { |c| allow(c).to receive(:new) { logged_in_adapter_instance } } 11 | end 12 | let(:logged_out_adapter_instance) { double } 13 | let(:logged_out_adapter) do 14 | Class.new.tap { |c| allow(c).to receive(:new) { logged_out_adapter_instance } } 15 | end 16 | 17 | context "when fallback_to_logged_out_adapter is false" do 18 | context "when logged in" do 19 | subject do 20 | described_class.with_config( 21 | logged_in: lambda { |context| true }, 22 | logged_in_adapter: logged_in_adapter, 23 | logged_out_adapter: logged_out_adapter, 24 | fallback_to_logged_out_adapter: false 25 | ).new(context) 26 | end 27 | 28 | it "#[]=" do 29 | expect(logged_in_adapter_instance).to receive(:[]=).with("my_key", "my_value") 30 | expect_any_instance_of(logged_out_adapter).not_to receive(:[]=) 31 | subject["my_key"] = "my_value" 32 | end 33 | 34 | it "#[]" do 35 | expect(logged_in_adapter_instance).to receive(:[]).with("my_key") { "my_value" } 36 | expect_any_instance_of(logged_out_adapter).not_to receive(:[]) 37 | expect(subject["my_key"]).to eq("my_value") 38 | end 39 | 40 | it "#delete" do 41 | expect(logged_in_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 42 | expect_any_instance_of(logged_out_adapter).not_to receive(:delete) 43 | expect(subject.delete("my_key")).to eq("my_value") 44 | end 45 | 46 | it "#keys" do 47 | expect(logged_in_adapter_instance).to receive(:keys) { ["my_value"] } 48 | expect_any_instance_of(logged_out_adapter).not_to receive(:keys) 49 | expect(subject.keys).to eq(["my_value"]) 50 | end 51 | end 52 | 53 | context "when logged out" do 54 | subject do 55 | described_class.with_config( 56 | logged_in: lambda { |context| false }, 57 | logged_in_adapter: logged_in_adapter, 58 | logged_out_adapter: logged_out_adapter, 59 | fallback_to_logged_out_adapter: false 60 | ).new(context) 61 | end 62 | 63 | it "#[]=" do 64 | expect_any_instance_of(logged_in_adapter).not_to receive(:[]=) 65 | expect(logged_out_adapter_instance).to receive(:[]=).with("my_key", "my_value") 66 | subject["my_key"] = "my_value" 67 | end 68 | 69 | it "#[]" do 70 | expect_any_instance_of(logged_in_adapter).not_to receive(:[]) 71 | expect(logged_out_adapter_instance).to receive(:[]).with("my_key") { "my_value" } 72 | expect(subject["my_key"]).to eq("my_value") 73 | end 74 | 75 | it "#delete" do 76 | expect_any_instance_of(logged_in_adapter).not_to receive(:delete) 77 | expect(logged_out_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 78 | expect(subject.delete("my_key")).to eq("my_value") 79 | end 80 | 81 | it "#keys" do 82 | expect_any_instance_of(logged_in_adapter).not_to receive(:keys) 83 | expect(logged_out_adapter_instance).to receive(:keys) { ["my_value", "my_value2"] } 84 | expect(subject.keys).to eq(["my_value", "my_value2"]) 85 | end 86 | end 87 | end 88 | 89 | context "when fallback_to_logged_out_adapter is true" do 90 | context "when logged in" do 91 | subject do 92 | described_class.with_config( 93 | logged_in: lambda { |context| true }, 94 | logged_in_adapter: logged_in_adapter, 95 | logged_out_adapter: logged_out_adapter, 96 | fallback_to_logged_out_adapter: true 97 | ).new(context) 98 | end 99 | 100 | it "#[]=" do 101 | expect(logged_in_adapter_instance).to receive(:[]=).with("my_key", "my_value") 102 | expect(logged_out_adapter_instance).to receive(:[]=).with("my_key", "my_value") 103 | expect(logged_out_adapter_instance).to receive(:[]).with("my_key") { nil } 104 | subject["my_key"] = "my_value" 105 | end 106 | 107 | it "#[]" do 108 | expect(logged_in_adapter_instance).to receive(:[]).with("my_key") { "my_value" } 109 | expect_any_instance_of(logged_out_adapter).not_to receive(:[]) 110 | expect(subject["my_key"]).to eq("my_value") 111 | end 112 | 113 | it "#delete" do 114 | expect(logged_in_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 115 | expect(logged_out_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 116 | expect(subject.delete("my_key")).to eq("my_value") 117 | end 118 | 119 | it "#keys" do 120 | expect(logged_in_adapter_instance).to receive(:keys) { ["my_value"] } 121 | expect(logged_out_adapter_instance).to receive(:keys) { ["my_value", "my_value2"] } 122 | expect(subject.keys).to eq(["my_value", "my_value2"]) 123 | end 124 | end 125 | 126 | context "when logged out" do 127 | subject do 128 | described_class.with_config( 129 | logged_in: lambda { |context| false }, 130 | logged_in_adapter: logged_in_adapter, 131 | logged_out_adapter: logged_out_adapter, 132 | fallback_to_logged_out_adapter: true 133 | ).new(context) 134 | end 135 | 136 | it "#[]=" do 137 | expect_any_instance_of(logged_in_adapter).not_to receive(:[]=) 138 | expect(logged_out_adapter_instance).to receive(:[]=).with("my_key", "my_value") 139 | expect(logged_out_adapter_instance).to receive(:[]).with("my_key") { nil } 140 | subject["my_key"] = "my_value" 141 | end 142 | 143 | it "#[]" do 144 | expect_any_instance_of(logged_in_adapter).not_to receive(:[]) 145 | expect(logged_out_adapter_instance).to receive(:[]).with("my_key") { "my_value" } 146 | expect(subject["my_key"]).to eq("my_value") 147 | end 148 | 149 | it "#delete" do 150 | expect(logged_in_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 151 | expect(logged_out_adapter_instance).to receive(:delete).with("my_key") { "my_value" } 152 | expect(subject.delete("my_key")).to eq("my_value") 153 | end 154 | 155 | it "#keys" do 156 | expect(logged_in_adapter_instance).to receive(:keys) { ["my_value"] } 157 | expect(logged_out_adapter_instance).to receive(:keys) { ["my_value", "my_value2"] } 158 | expect(subject.keys).to eq(["my_value", "my_value2"]) 159 | end 160 | end 161 | end 162 | 163 | describe "when errors in config" do 164 | before { described_class.config.clear } 165 | let(:some_proc) { -> { } } 166 | 167 | it "when no logged in adapter" do 168 | expect { 169 | described_class.with_config( 170 | logged_in: some_proc, 171 | logged_out_adapter: logged_out_adapter 172 | ).new(context) 173 | }.to raise_error(StandardError, /:logged_in_adapter/) 174 | end 175 | 176 | it "when no logged out adapter" do 177 | expect { 178 | described_class.with_config( 179 | logged_in: some_proc, 180 | logged_in_adapter: logged_in_adapter 181 | ).new(context) 182 | }.to raise_error(StandardError, /:logged_out_adapter/) 183 | end 184 | 185 | it "when no logged in detector" do 186 | expect { 187 | described_class.with_config( 188 | logged_in_adapter: logged_in_adapter, 189 | logged_out_adapter: logged_out_adapter 190 | ).new(context) 191 | }.to raise_error(StandardError, /:logged_in$/) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /spec/persistence/redis_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Persistence::RedisAdapter do 6 | let(:context) { double(lookup: "blah") } 7 | 8 | subject { Split::Persistence::RedisAdapter.new(context) } 9 | 10 | describe "#redis_key" do 11 | before { Split::Persistence::RedisAdapter.reset_config! } 12 | 13 | context "default" do 14 | it "should raise error with prompt to set lookup_by" do 15 | expect { Split::Persistence::RedisAdapter.new(context) }.to raise_error(RuntimeError) 16 | end 17 | end 18 | 19 | context "config with key" do 20 | before { Split::Persistence::RedisAdapter.reset_config! } 21 | subject { Split::Persistence::RedisAdapter.new(context, "manual") } 22 | 23 | it 'should be "persistence:manual"' do 24 | expect(subject.redis_key).to eq("persistence:manual") 25 | end 26 | end 27 | 28 | context 'config with lookup_by = proc { "block" }' do 29 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: proc { "block" }) } 30 | 31 | it 'should be "persistence:block"' do 32 | expect(subject.redis_key).to eq("persistence:block") 33 | end 34 | end 35 | 36 | context "config with lookup_by = proc { |context| context.test }" do 37 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: proc { "block" }) } 38 | let(:context) { double(test: "block") } 39 | 40 | it 'should be "persistence:block"' do 41 | expect(subject.redis_key).to eq("persistence:block") 42 | end 43 | end 44 | 45 | context 'config with lookup_by = "method_name"' do 46 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: "method_name") } 47 | let(:context) { double(method_name: "val") } 48 | 49 | it 'should be "persistence:bar"' do 50 | expect(subject.redis_key).to eq("persistence:val") 51 | end 52 | end 53 | 54 | context "config with namespace and lookup_by" do 55 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: proc { "frag" }, namespace: "namer") } 56 | 57 | it 'should be "namer"' do 58 | expect(subject.redis_key).to eq("namer:frag") 59 | end 60 | end 61 | end 62 | 63 | describe "#find" do 64 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: proc { "frag" }, namespace: "a_namespace") } 65 | 66 | it "should create and user from a given key" do 67 | adapter = Split::Persistence::RedisAdapter.find(2) 68 | expect(adapter.redis_key).to eq("a_namespace:2") 69 | end 70 | end 71 | 72 | context "functional tests" do 73 | before { Split::Persistence::RedisAdapter.with_config(lookup_by: "lookup") } 74 | 75 | describe "#[] and #[]=" do 76 | it "should convert to string, set and return the value for given key" do 77 | subject["my_key"] = true 78 | expect(subject["my_key"]).to eq("true") 79 | end 80 | end 81 | 82 | describe "#delete" do 83 | it "should delete the given key" do 84 | subject["my_key"] = "my_value" 85 | subject.delete("my_key") 86 | expect(subject["my_key"]).to be_nil 87 | end 88 | end 89 | 90 | describe "#keys" do 91 | it "should return an array of the user's stored keys" do 92 | subject["my_key"] = "my_value" 93 | subject["my_second_key"] = "my_second_value" 94 | expect(subject.keys).to match(["my_key", "my_second_key"]) 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/persistence/session_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Persistence::SessionAdapter do 6 | let(:context) { double(session: {}) } 7 | subject { Split::Persistence::SessionAdapter.new(context) } 8 | 9 | describe "#[] and #[]=" do 10 | it "should set and return the value for given key" do 11 | subject["my_key"] = "my_value" 12 | expect(subject["my_key"]).to eq("my_value") 13 | end 14 | end 15 | 16 | describe "#delete" do 17 | it "should delete the given key" do 18 | subject["my_key"] = "my_value" 19 | subject.delete("my_key") 20 | expect(subject["my_key"]).to be_nil 21 | end 22 | end 23 | 24 | describe "#keys" do 25 | it "should return an array of the session's stored keys" do 26 | subject["my_key"] = "my_value" 27 | subject["my_second_key"] = "my_second_value" 28 | expect(subject.keys).to match(["my_key", "my_second_key"]) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/persistence_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::Persistence do 6 | subject { Split::Persistence } 7 | 8 | describe ".adapter" do 9 | context "when the persistence config is a symbol" do 10 | it "should return the appropriate adapter for the symbol" do 11 | expect(Split.configuration).to receive(:persistence).twice.and_return(:cookie) 12 | expect(subject.adapter).to eq(Split::Persistence::CookieAdapter) 13 | end 14 | 15 | it "should return an adapter whose class is present in Split::Persistence::ADAPTERS" do 16 | expect(Split.configuration).to receive(:persistence).twice.and_return(:cookie) 17 | expect(Split::Persistence::ADAPTERS.values).to include(subject.adapter) 18 | end 19 | 20 | it "should raise if the adapter cannot be found" do 21 | expect(Split.configuration).to receive(:persistence).twice.and_return(:something_weird) 22 | expect { subject.adapter }.to raise_error(Split::InvalidPersistenceAdapterError) 23 | end 24 | end 25 | context "when the persistence config is a class" do 26 | let(:custom_adapter_class) { MyCustomAdapterClass = Class.new } 27 | it "should return that class" do 28 | expect(Split.configuration).to receive(:persistence).twice.and_return(custom_adapter_class) 29 | expect(subject.adapter).to eq(MyCustomAdapterClass) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/redis_interface_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe Split::RedisInterface do 6 | let(:list_name) { "list_name" } 7 | let(:set_name) { "set_name" } 8 | let(:interface) { described_class.new } 9 | 10 | describe "#persist_list" do 11 | subject(:persist_list) do 12 | interface.persist_list(list_name, %w(a b c d)) 13 | end 14 | 15 | specify do 16 | expect(persist_list).to eq %w(a b c d) 17 | expect(Split.redis.lindex(list_name, 0)).to eq "a" 18 | expect(Split.redis.lindex(list_name, 1)).to eq "b" 19 | expect(Split.redis.lindex(list_name, 2)).to eq "c" 20 | expect(Split.redis.lindex(list_name, 3)).to eq "d" 21 | expect(Split.redis.llen(list_name)).to eq 4 22 | end 23 | 24 | context "list is overwritten but not deleted" do 25 | specify do 26 | expect(persist_list).to eq %w(a b c d) 27 | interface.persist_list(list_name, ["z"]) 28 | expect(Split.redis.lindex(list_name, 0)).to eq "z" 29 | expect(Split.redis.llen(list_name)).to eq 1 30 | end 31 | end 32 | end 33 | 34 | describe "#add_to_set" do 35 | subject(:add_to_set) do 36 | interface.add_to_set(set_name, "something") 37 | end 38 | 39 | specify do 40 | add_to_set 41 | expect(Split.redis.sismember(set_name, "something")).to be true 42 | end 43 | 44 | context "when a Redis version is used that supports the 'sadd?' method" do 45 | before { expect(Split.redis).to receive(:respond_to?).with(:sadd?).and_return(true) } 46 | 47 | it "will use this method instead of 'sadd'" do 48 | expect(Split.redis).to receive(:sadd?).with(set_name, "something") 49 | expect(Split.redis).not_to receive(:sadd).with(set_name, "something") 50 | add_to_set 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RACK_ENV"] = "test" 4 | 5 | require "rubygems" 6 | require "bundler/setup" 7 | 8 | require "simplecov" 9 | SimpleCov.start 10 | 11 | require "split" 12 | require "ostruct" 13 | require "yaml" 14 | require "pry" 15 | 16 | Dir["./spec/support/*.rb"].each { |f| require f } 17 | 18 | module GlobalSharedContext 19 | extend RSpec::SharedContext 20 | let(:mock_user) { Split::User.new(double(session: {})) } 21 | 22 | before(:each) do 23 | Split.configuration = Split::Configuration.new 24 | Split.redis = Redis.new 25 | Split.redis.select(10) 26 | Split.redis.flushdb 27 | Split::Cache.clear 28 | @ab_user = mock_user 29 | @params = nil 30 | end 31 | end 32 | 33 | RSpec.configure do |config| 34 | config.order = "random" 35 | config.include GlobalSharedContext 36 | config.raise_errors_for_deprecations! 37 | end 38 | 39 | def session 40 | @session ||= {} 41 | end 42 | 43 | def params 44 | @params ||= {} 45 | end 46 | 47 | def request 48 | @request ||= build_request 49 | end 50 | 51 | def build_request( 52 | ua: "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_6; de-de) AppleWebKit/533.20.25 (KHTML, like Gecko) Version/5.0.4 Safari/533.20.27", 53 | ip: "192.168.1.1", 54 | params: {}, 55 | cookies: {} 56 | ) 57 | r = OpenStruct.new 58 | r.user_agent = ua 59 | r.ip = ip 60 | r.params = params 61 | r.cookies = cookies 62 | r 63 | end 64 | -------------------------------------------------------------------------------- /spec/split_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Split do 6 | around(:each) do |ex| 7 | old_env, old_redis = [ENV.delete("REDIS_URL"), Split.redis] 8 | ex.run 9 | ENV["REDIS_URL"] = old_env 10 | Split.redis = old_redis 11 | end 12 | 13 | describe "#redis=" do 14 | it "accepts a url string" do 15 | Split.redis = "redis://localhost:6379" 16 | expect(Split.redis).to be_a(Redis) 17 | 18 | client = Split.redis.connection 19 | expect(client[:host]).to eq("localhost") 20 | expect(client[:port]).to eq(6379) 21 | end 22 | 23 | it "accepts an options hash" do 24 | Split.redis = { host: "localhost", port: 6379, db: 12 } 25 | expect(Split.redis).to be_a(Redis) 26 | 27 | client = Split.redis.connection 28 | expect(client[:host]).to eq("localhost") 29 | expect(client[:port]).to eq(6379) 30 | expect(client[:db]).to eq(12) 31 | end 32 | 33 | it "accepts a valid Redis instance" do 34 | other_redis = Redis.new(url: "redis://localhost:6379") 35 | Split.redis = other_redis 36 | expect(Split.redis).to eq(other_redis) 37 | end 38 | 39 | it "raises an ArgumentError when server cannot be determined" do 40 | expect { Split.redis = Object.new }.to raise_error(ArgumentError) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/support/cookies_mock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CookiesMock 4 | def initialize 5 | @cookies = {} 6 | end 7 | 8 | def []=(key, value) 9 | @cookies[key] = value[:value] 10 | end 11 | 12 | def [](key) 13 | @cookies[key] 14 | end 15 | 16 | def delete(key) 17 | @cookies.delete(key) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/trial_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/trial" 5 | 6 | describe Split::Trial do 7 | let(:user) { mock_user } 8 | let(:alternatives) { ["basket", "cart"] } 9 | let(:experiment) do 10 | Split::Experiment.new("basket_text", alternatives: alternatives).save 11 | end 12 | 13 | it "should be initializeable" do 14 | experiment = double("experiment") 15 | alternative = double("alternative", kind_of?: Split::Alternative) 16 | trial = Split::Trial.new(experiment: experiment, alternative: alternative) 17 | expect(trial.experiment).to eq(experiment) 18 | expect(trial.alternative).to eq(alternative) 19 | end 20 | 21 | describe "alternative" do 22 | it "should use the alternative if specified" do 23 | alternative = double("alternative", kind_of?: Split::Alternative) 24 | trial = Split::Trial.new(experiment: double("experiment"), 25 | alternative: alternative, user: user) 26 | expect(trial).not_to receive(:choose) 27 | expect(trial.alternative).to eq(alternative) 28 | end 29 | 30 | it "should load the alternative when the alternative name is set" do 31 | experiment = Split::Experiment.new("basket_text", alternatives: ["basket", "cart"]) 32 | experiment.save 33 | 34 | trial = Split::Trial.new(experiment: experiment, alternative: "basket") 35 | expect(trial.alternative.name).to eq("basket") 36 | end 37 | end 38 | 39 | describe "metadata" do 40 | let(:metadata) { Hash[alternatives.map { |k| [k, "Metadata for #{k}"] }] } 41 | let(:experiment) do 42 | Split::Experiment.new("basket_text", alternatives: alternatives, metadata: metadata).save 43 | end 44 | 45 | it "has metadata on each trial" do 46 | trial = Split::Trial.new(experiment: experiment, user: user, metadata: metadata["cart"], 47 | override: "cart") 48 | expect(trial.metadata).to eq(metadata["cart"]) 49 | end 50 | 51 | it "has metadata on each trial from the experiment" do 52 | trial = Split::Trial.new(experiment: experiment, user: user) 53 | trial.choose! 54 | expect(trial.metadata).to eq(metadata[trial.alternative.name]) 55 | expect(trial.metadata).to match(/#{trial.alternative.name}/) 56 | end 57 | end 58 | 59 | describe "#choose!" do 60 | let(:context) { double(on_trial_callback: "test callback") } 61 | let(:trial) do 62 | Split::Trial.new(user: user, experiment: experiment) 63 | end 64 | 65 | shared_examples_for "a trial with callbacks" do 66 | it "does not run if on_trial callback is not respondable" do 67 | Split.configuration.on_trial = :foo 68 | allow(context).to receive(:respond_to?).with(:foo, true).and_return false 69 | expect(context).to_not receive(:foo) 70 | trial.choose! context 71 | end 72 | it "runs on_trial callback" do 73 | Split.configuration.on_trial = :on_trial_callback 74 | expect(context).to receive(:on_trial_callback) 75 | trial.choose! context 76 | end 77 | it "does not run nil on_trial callback" do 78 | Split.configuration.on_trial = nil 79 | expect(context).not_to receive(:on_trial_callback) 80 | trial.choose! context 81 | end 82 | end 83 | 84 | def expect_alternative(trial, alternative_name) 85 | 3.times do 86 | trial.choose! context 87 | expect(alternative_name).to include(trial.alternative.name) 88 | end 89 | end 90 | 91 | context "when override is present" do 92 | let(:override) { "cart" } 93 | let(:trial) do 94 | Split::Trial.new(user: user, experiment: experiment, override: override) 95 | end 96 | 97 | it_behaves_like "a trial with callbacks" 98 | 99 | it "picks the override" do 100 | expect(experiment).to_not receive(:next_alternative) 101 | expect_alternative(trial, override) 102 | end 103 | 104 | context "when alternative doesn't exist" do 105 | let(:override) { nil } 106 | it "falls back on next_alternative" do 107 | expect(experiment).to receive(:next_alternative).and_call_original 108 | expect_alternative(trial, alternatives) 109 | end 110 | end 111 | end 112 | 113 | context "when disabled option is true" do 114 | let(:trial) do 115 | Split::Trial.new(user: user, experiment: experiment, disabled: true) 116 | end 117 | 118 | it "picks the control", :aggregate_failures do 119 | Split.configuration.on_trial = :on_trial_callback 120 | expect(experiment).to_not receive(:next_alternative) 121 | 122 | expect(context).not_to receive(:on_trial_callback) 123 | 124 | expect_alternative(trial, "basket") 125 | Split.configuration.on_trial = nil 126 | end 127 | end 128 | 129 | context "when Split is globally disabled" do 130 | it "picks the control and does not run on_trial callbacks", :aggregate_failures do 131 | Split.configuration.enabled = false 132 | Split.configuration.on_trial = :on_trial_callback 133 | 134 | expect(experiment).to_not receive(:next_alternative) 135 | expect(context).not_to receive(:on_trial_callback) 136 | expect_alternative(trial, "basket") 137 | 138 | Split.configuration.enabled = true 139 | Split.configuration.on_trial = nil 140 | end 141 | end 142 | 143 | context "when experiment has winner" do 144 | let(:trial) do 145 | Split::Trial.new(user: user, experiment: experiment) 146 | end 147 | 148 | it_behaves_like "a trial with callbacks" 149 | 150 | it "picks the winner" do 151 | experiment.winner = "cart" 152 | expect(experiment).to_not receive(:next_alternative) 153 | 154 | expect_alternative(trial, "cart") 155 | end 156 | end 157 | 158 | context "when exclude is true" do 159 | let(:trial) do 160 | Split::Trial.new(user: user, experiment: experiment, exclude: true) 161 | end 162 | 163 | it_behaves_like "a trial with callbacks" 164 | 165 | it "picks the control" do 166 | expect(experiment).to_not receive(:next_alternative) 167 | expect_alternative(trial, "basket") 168 | end 169 | end 170 | 171 | context "when user is already participating" do 172 | it_behaves_like "a trial with callbacks" 173 | 174 | it "picks the same alternative" do 175 | user[experiment.key] = "basket" 176 | expect(experiment).to_not receive(:next_alternative) 177 | 178 | expect_alternative(trial, "basket") 179 | end 180 | 181 | context "when alternative is not found" do 182 | it "falls back on next_alternative" do 183 | user[experiment.key] = "notfound" 184 | expect(experiment).to receive(:next_alternative).and_call_original 185 | expect_alternative(trial, alternatives) 186 | end 187 | end 188 | end 189 | 190 | context "when user is a new participant" do 191 | it "picks a new alternative and runs on_trial_choose callback", :aggregate_failures do 192 | Split.configuration.on_trial_choose = :on_trial_choose_callback 193 | 194 | expect(experiment).to receive(:next_alternative).and_call_original 195 | expect(context).to receive(:on_trial_choose_callback) 196 | 197 | trial.choose! context 198 | 199 | expect(trial.alternative.name).to_not be_empty 200 | Split.configuration.on_trial_choose = nil 201 | end 202 | 203 | it "assigns user to an alternative" do 204 | trial.choose! context 205 | 206 | expect(alternatives).to include(user[experiment.name]) 207 | end 208 | 209 | context "when cohorting is disabled" do 210 | before(:each) { allow(experiment).to receive(:cohorting_disabled?).and_return(true) } 211 | 212 | it "picks the control and does not run on_trial callbacks" do 213 | Split.configuration.on_trial = :on_trial_callback 214 | 215 | expect(experiment).to_not receive(:next_alternative) 216 | expect(context).not_to receive(:on_trial_callback) 217 | expect_alternative(trial, "basket") 218 | 219 | Split.configuration.enabled = true 220 | Split.configuration.on_trial = nil 221 | end 222 | 223 | it "user is not assigned an alternative" do 224 | trial.choose! context 225 | 226 | expect(user[experiment]).to eq(nil) 227 | end 228 | end 229 | end 230 | end 231 | 232 | describe "#complete!" do 233 | context "when there are no goals" do 234 | let(:trial) { Split::Trial.new(user: user, experiment: experiment) } 235 | it "should complete the trial" do 236 | trial.choose! 237 | old_completed_count = trial.alternative.completed_count 238 | trial.complete! 239 | expect(trial.alternative.completed_count).to eq(old_completed_count + 1) 240 | end 241 | end 242 | 243 | context "when there are many goals" do 244 | let(:goals) { [ "goal1", "goal2" ] } 245 | let(:trial) { Split::Trial.new(user: user, experiment: experiment, goals: goals) } 246 | 247 | it "increments the completed count corresponding to the goals" do 248 | trial.choose! 249 | old_completed_counts = goals.map { |goal| [goal, trial.alternative.completed_count(goal)] }.to_h 250 | trial.complete! 251 | goals.each { | goal | expect(trial.alternative.completed_count(goal)).to eq(old_completed_counts[goal] + 1) } 252 | end 253 | end 254 | 255 | context "when there is 1 goal of type string" do 256 | let(:goal) { "goal" } 257 | let(:trial) { Split::Trial.new(user: user, experiment: experiment, goals: goal) } 258 | it "increments the completed count corresponding to the goal" do 259 | trial.choose! 260 | old_completed_count = trial.alternative.completed_count(goal) 261 | trial.complete! 262 | expect(trial.alternative.completed_count(goal)).to eq(old_completed_count + 1) 263 | end 264 | end 265 | end 266 | 267 | describe "alternative recording" do 268 | before(:each) { Split.configuration.store_override = false } 269 | 270 | context "when override is present" do 271 | it "stores when store_override is true" do 272 | trial = Split::Trial.new(user: user, experiment: experiment, override: "basket") 273 | 274 | Split.configuration.store_override = true 275 | expect(user).to receive("[]=") 276 | trial.choose! 277 | expect(trial.alternative.participant_count).to eq(1) 278 | end 279 | 280 | it "does not store when store_override is false" do 281 | trial = Split::Trial.new(user: user, experiment: experiment, override: "basket") 282 | 283 | expect(user).to_not receive("[]=") 284 | trial.choose! 285 | end 286 | end 287 | 288 | context "when disabled is present" do 289 | it "stores when store_override is true" do 290 | trial = Split::Trial.new(user: user, experiment: experiment, disabled: true) 291 | 292 | Split.configuration.store_override = true 293 | expect(user).to receive("[]=") 294 | trial.choose! 295 | end 296 | 297 | it "does not store when store_override is false" do 298 | trial = Split::Trial.new(user: user, experiment: experiment, disabled: true) 299 | 300 | expect(user).to_not receive("[]=") 301 | trial.choose! 302 | end 303 | end 304 | 305 | context "when exclude is present" do 306 | it "does not store" do 307 | trial = Split::Trial.new(user: user, experiment: experiment, exclude: true) 308 | 309 | expect(user).to_not receive("[]=") 310 | trial.choose! 311 | end 312 | end 313 | 314 | context "when experiment has winner" do 315 | let(:trial) do 316 | experiment.winner = "cart" 317 | Split::Trial.new(user: user, experiment: experiment) 318 | end 319 | 320 | it "does not store" do 321 | expect(user).to_not receive("[]=") 322 | trial.choose! 323 | end 324 | end 325 | end 326 | end 327 | -------------------------------------------------------------------------------- /spec/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "split/experiment_catalog" 5 | require "split/experiment" 6 | require "split/user" 7 | 8 | describe Split::User do 9 | let(:user_keys) { { "link_color" => "blue" } } 10 | let(:context) { double(session: { split: user_keys }) } 11 | let(:experiment) { Split::Experiment.new("link_color") } 12 | 13 | before(:each) do 14 | @subject = described_class.new(context) 15 | end 16 | 17 | it "delegates methods correctly" do 18 | expect(@subject["link_color"]).to eq(@subject.user["link_color"]) 19 | end 20 | 21 | context "#cleanup_old_versions!" do 22 | let(:experiment_version) { "#{experiment.name}:1" } 23 | let(:second_experiment_version) { "#{experiment.name}_another:1" } 24 | let(:third_experiment_version) { "variation_of_#{experiment.name}:1" } 25 | let(:user_keys) do 26 | { 27 | experiment_version => "blue", 28 | second_experiment_version => "red", 29 | third_experiment_version => "yellow" 30 | } 31 | end 32 | 33 | before(:each) { @subject.cleanup_old_versions!(experiment) } 34 | 35 | it "removes key if old experiment is found" do 36 | expect(@subject.keys).not_to include(experiment_version) 37 | end 38 | 39 | it "does not remove other keys" do 40 | expect(@subject.keys).to include(second_experiment_version, third_experiment_version) 41 | end 42 | end 43 | 44 | context "#cleanup_old_experiments!" do 45 | it "removes key if experiment is not found" do 46 | @subject.cleanup_old_experiments! 47 | expect(@subject.keys).to be_empty 48 | end 49 | 50 | it "removes key if experiment has a winner" do 51 | allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) 52 | allow(experiment).to receive(:start_time).and_return(Date.today) 53 | allow(experiment).to receive(:has_winner?).and_return(true) 54 | @subject.cleanup_old_experiments! 55 | expect(@subject.keys).to be_empty 56 | end 57 | 58 | it "removes key if experiment has not started yet" do 59 | allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) 60 | allow(experiment).to receive(:has_winner?).and_return(false) 61 | @subject.cleanup_old_experiments! 62 | expect(@subject.keys).to be_empty 63 | end 64 | 65 | context "with finished key" do 66 | let(:user_keys) { { "link_color" => "blue", "link_color:finished" => true } } 67 | 68 | it "does not remove finished key for experiment without a winner" do 69 | allow(Split::ExperimentCatalog).to receive(:find).with("link_color").and_return(experiment) 70 | allow(Split::ExperimentCatalog).to receive(:find).with("link_color:finished").and_return(nil) 71 | allow(experiment).to receive(:start_time).and_return(Date.today) 72 | allow(experiment).to receive(:has_winner?).and_return(false) 73 | @subject.cleanup_old_experiments! 74 | expect(@subject.keys).to include("link_color") 75 | expect(@subject.keys).to include("link_color:finished") 76 | end 77 | end 78 | 79 | context "when already cleaned up" do 80 | before do 81 | @subject.cleanup_old_experiments! 82 | end 83 | 84 | it "does not clean up again" do 85 | expect(@subject).to_not receive(:keys_without_finished) 86 | @subject.cleanup_old_experiments! 87 | end 88 | end 89 | end 90 | 91 | context "allows user to be loaded from adapter" do 92 | it "loads user from adapter (RedisAdapter)" do 93 | user = Split::Persistence::RedisAdapter.new(nil, 112233) 94 | user["foo"] = "bar" 95 | 96 | ab_user = Split::User.find(112233, :redis) 97 | 98 | expect(ab_user["foo"]).to eql("bar") 99 | end 100 | 101 | it "returns nil if adapter does not implement a finder method" do 102 | ab_user = Split::User.find(112233, :dual_adapter) 103 | expect(ab_user).to be_nil 104 | end 105 | end 106 | 107 | context "instantiated with custom adapter" do 108 | let(:custom_adapter) { double(:persistence_adapter) } 109 | 110 | before do 111 | @subject = described_class.new(context, custom_adapter) 112 | end 113 | 114 | it "sets user to the custom adapter" do 115 | expect(@subject.user).to eq(custom_adapter) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /split.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | $:.push File.expand_path("../lib", __FILE__) 5 | require "split/version" 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "split" 9 | s.version = Split::VERSION 10 | s.platform = Gem::Platform::RUBY 11 | s.authors = ["Andrew Nesbitt"] 12 | s.licenses = ["MIT"] 13 | s.email = ["andrewnez@gmail.com"] 14 | s.homepage = "https://github.com/splitrb/split" 15 | s.summary = "Rack based split testing framework" 16 | 17 | s.metadata = { 18 | "homepage_uri" => "https://github.com/splitrb/split", 19 | "changelog_uri" => "https://github.com/splitrb/split/blob/main/CHANGELOG.md", 20 | "source_code_uri" => "https://github.com/splitrb/split", 21 | "bug_tracker_uri" => "https://github.com/splitrb/split/issues", 22 | "wiki_uri" => "https://github.com/splitrb/split/wiki", 23 | "mailing_list_uri" => "https://groups.google.com/d/forum/split-ruby", 24 | "funding_uri" => "https://opencollective.com/split" 25 | } 26 | 27 | s.required_ruby_version = ">= 2.5.0" 28 | s.required_rubygems_version = ">= 2.0.0" 29 | 30 | s.files = `git ls-files`.split("\n") 31 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 32 | s.require_paths = ["lib"] 33 | 34 | s.add_dependency "redis", ">= 4.2" 35 | s.add_dependency "sinatra", ">= 1.2.6" 36 | s.add_dependency "rubystats", ">= 0.3.0" 37 | s.add_dependency "matrix" 38 | s.add_dependency "bigdecimal" 39 | 40 | s.add_development_dependency "bundler", ">= 1.17" 41 | s.add_development_dependency "simplecov", "~> 0.15" 42 | s.add_development_dependency "rack-test", "~> 2.0" 43 | s.add_development_dependency "rake", "~> 13" 44 | s.add_development_dependency "rspec", "~> 3.7" 45 | s.add_development_dependency "pry", "~> 0.10" 46 | s.add_development_dependency "rails", ">= 5.0" 47 | end 48 | --------------------------------------------------------------------------------