├── .babelrc ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── LICENSE ├── README.md ├── circle.yml ├── documentation-images └── debugger-animated-2.gif ├── examples ├── browser │ ├── README.md │ ├── app.jsx │ ├── index.html │ ├── package.json │ └── webpack.example.config.js └── isomorphic │ ├── Component.jsx │ ├── README.md │ ├── package.json │ ├── server.js │ ├── views │ └── template.ejs │ ├── webpack.example.config.js │ └── www │ └── app.jsx ├── index.js ├── lib ├── CoreExperiment.js ├── Experiment.js ├── Variant.js ├── debugger.js ├── emitter.js └── helpers │ ├── mixpanel.js │ └── segment.js ├── package.json ├── src ├── CoreExperiment.jsx ├── Experiment.jsx ├── Variant.jsx ├── debugger.jsx ├── emitter.jsx └── helpers │ ├── mixpanel.jsx │ └── segment.jsx ├── standalone.min.js ├── test ├── browser │ ├── core.test.jsx │ ├── debugger.test.jsx │ ├── emitter.test.jsx │ ├── experiment.test.jsx │ ├── helpers.mixpanel.test.jsx │ ├── helpers.segment.test.jsx │ ├── karma.conf.js │ ├── tests.bundle.js │ ├── variant.test.jsx │ └── weighted.test.jsx └── isomorphic │ └── experiment.jsx ├── webpack.standalone.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["stage-1", "es2015", "react"], 3 | "plugins": ["transform-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true, 7 | "es6": true, 8 | }, 9 | "globals": { 10 | "assert": true 11 | }, 12 | "plugins": [ 13 | "babel", 14 | "react", 15 | "promise" 16 | ], 17 | "rules": { 18 | "camelcase": 0, 19 | "no-sequences": 0, 20 | "no-extra-parens": 2, 21 | "no-loop-func": 2, 22 | "require-yield": 2 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Table of Contents

4 | 5 | - [Expected Behavior](#expected-behavior) 6 | - [Current Behavior](#current-behavior) 7 | - [Possible Solution](#possible-solution) 8 | - [Steps to Reproduce (for bugs)](#steps-to-reproduce-for-bugs) 9 | - [Context](#context) 10 | - [Your Environment](#your-environment) 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | ## Expected Behavior 21 | 22 | 23 | 24 | ## Current Behavior 25 | 26 | 27 | 28 | ## Possible Solution 29 | 30 | 31 | 32 | ## Steps to Reproduce (for bugs) 33 | 34 | 35 | 1. 36 | 2. 37 | 3. 38 | 4. 39 | 40 | ## Context 41 | 42 | 43 | 44 | ## Your Environment 45 | 46 | * Version used: 47 | * Browser Name and version: 48 | * Operating System and version (desktop or mobile): 49 | * Link to your project: 50 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Table of Contents

4 | 5 | - [Description](#description) 6 | - [Motivation and Context](#motivation-and-context) 7 | - [How Has This Been Tested?](#how-has-this-been-tested) 8 | - [Screenshots (if appropriate):](#screenshots-if-appropriate) 9 | - [Types of changes](#types-of-changes) 10 | - [Checklist:](#checklist) 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | ## Description 21 | 22 | 23 | ## Motivation and Context 24 | 25 | 26 | 27 | ## How Has This Been Tested? 28 | 29 | 30 | 31 | 32 | ## Screenshots (if appropriate): 33 | 34 | ## Types of changes 35 | 36 | - [ ] Bug fix (non-breaking change which fixes an issue) 37 | - [ ] New feature (non-breaking change which adds functionality) 38 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 39 | 40 | ## Checklist: 41 | 42 | 43 | - [ ] My code follows the code style of this project. 44 | - [ ] My change requires a change to the documentation. 45 | - [ ] I have updated the documentation accordingly. 46 | - [ ] I have added tests to cover my changes. 47 | - [ ] All new and existing tests passed. 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | run-browserstack-tests.sh 30 | 31 | bundle.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2015, John Wehr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A/B Testing React Components 2 | 3 | [![NPM Version](https://badge.fury.io/js/react-ab-test.svg)](https://www.npmjs.com/package/react-ab-test) 4 | [![Circle CI](https://circleci.com/gh/pushtell/react-ab-test.svg?style=shield)](https://circleci.com/gh/pushtell/react-ab-test) 5 | [![Coverage Status](https://coveralls.io/repos/pushtell/react-ab-test/badge.svg?branch=master&service=github)](https://coveralls.io/github/pushtell/react-ab-test?branch=master) 6 | [![Dependency Status](https://david-dm.org/pushtell/react-ab-test.svg)](https://david-dm.org/pushtell/react-ab-test) 7 | [![NPM Downloads](https://img.shields.io/npm/dm/react-ab-test.svg?style=flat)](https://www.npmjs.com/package/react-ab-test) 8 | 9 | Wrap components in [``](#variant-) and nest in [``](#experiment-). A variant is chosen randomly and saved to local storage. 10 | 11 | ```js 12 | 13 | 14 |
Version A
15 |
16 | 17 |
Version B
18 |
19 |
20 | ``` 21 | 22 | Report to your analytics provider using the [`emitter`](#emitter). Helpers are available for [Mixpanel](#mixpanelhelper) and [Segment.com](#segmenthelper). 23 | 24 | ```js 25 | emitter.addPlayListener(function(experimentName, variantName){ 26 | mixpanel.track("Start Experiment", {name: experimentName, variant: variantName}); 27 | }); 28 | ``` 29 | 30 | Please [★ on GitHub](https://github.com/pushtell/react-ab-test)! 31 | 32 | 33 | 34 |

Table of Contents

35 | 36 | - [Installation](#installation) 37 | - [Usage](#usage) 38 | - [Standalone Component](#standalone-component) 39 | - [Coordinate Multiple Components](#coordinate-multiple-components) 40 | - [Weighting Variants](#weighting-variants) 41 | - [Debugging](#debugging) 42 | - [Server Rendering](#server-rendering) 43 | - [Example](#example) 44 | - [With Babel](#with-babel) 45 | - [Alternative Libraries](#alternative-libraries) 46 | - [Resources for A/B Testing with React](#resources-for-ab-testing-with-react) 47 | - [API Reference](#api-reference) 48 | - [``](#experiment-) 49 | - [``](#variant-) 50 | - [`emitter`](#emitter) 51 | - [`emitter.emitWin(experimentName)`](#emitteremitwinexperimentname) 52 | - [`emitter.addActiveVariantListener([experimentName, ] callback)`](#emitteraddactivevariantlistenerexperimentname--callback) 53 | - [`emitter.addPlayListener([experimentName, ] callback)`](#emitteraddplaylistenerexperimentname--callback) 54 | - [`emitter.addWinListener([experimentName, ] callback)`](#emitteraddwinlistenerexperimentname--callback) 55 | - [`emitter.defineVariants(experimentName, variantNames [, variantWeights])`](#emitterdefinevariantsexperimentname-variantnames--variantweights) 56 | - [`emitter.setActiveVariant(experimentName, variantName)`](#emittersetactivevariantexperimentname-variantname) 57 | - [`emitter.getActiveVariant(experimentName)`](#emittergetactivevariantexperimentname) 58 | - [`emitter.getSortedVariants(experimentName)`](#emittergetsortedvariantsexperimentname) 59 | - [`Subscription`](#subscription) 60 | - [`subscription.remove()`](#subscriptionremove) 61 | - [`experimentDebugger`](#experimentdebugger) 62 | - [`experimentDebugger.enable()`](#experimentdebuggerenable) 63 | - [`experimentDebugger.disable()`](#experimentdebuggerdisable) 64 | - [`mixpanelHelper`](#mixpanelhelper) 65 | - [Usage](#usage-1) 66 | - [`mixpanelHelper.enable()`](#mixpanelhelperenable) 67 | - [`mixpanelHelper.disable()`](#mixpanelhelperdisable) 68 | - [`segmentHelper`](#segmenthelper) 69 | - [Usage](#usage-2) 70 | - [`segmentHelper.enable()`](#segmenthelperenable) 71 | - [`segmentHelper.disable()`](#segmenthelperdisable) 72 | - [How to contribute](#how-to-contribute) 73 | - [Requisites](#requisites) 74 | - [Browser Coverage](#browser-coverage) 75 | - [Running Tests](#running-tests) 76 | 77 | 78 | 79 | ## Installation 80 | 81 | `react-ab-test` is compatible with React 0.14.x and 0.15.x. 82 | 83 | ```bash 84 | npm install react-ab-test 85 | ``` 86 | 87 | ## Usage 88 | 89 | ### Standalone Component 90 | 91 | Try it [on JSFiddle](https://jsfiddle.net/pushtell/m14qvy7r/) 92 | 93 | ```js 94 | 95 | var Experiment = require("react-ab-test/lib/Experiment"); 96 | var Variant = require("react-ab-test/lib/Variant"); 97 | var emitter = require("react-ab-test/lib/emitter"); 98 | 99 | var App = React.createClass({ 100 | onButtonClick: function(e){ 101 | this.refs.experiment.win(); 102 | }, 103 | render: function(){ 104 | return
105 | 106 | 107 |
Section A
108 |
109 | 110 |
Section B
111 |
112 |
113 | 114 |
; 115 | } 116 | }); 117 | 118 | // Called when the experiment is displayed to the user. 119 | emitter.addPlayListener(function(experimentName, variantName){ 120 | console.log("Displaying experiment ‘" + experimentName + "’ variant ‘" + variantName + "’"); 121 | }); 122 | 123 | // Called when a 'win' is emitted, in this case by this.refs.experiment.win() 124 | emitter.addWinListener(function(experimentName, variantName){ 125 | console.log("Variant ‘" + variantName + "’ of experiment ‘" + experimentName + "’ was clicked"); 126 | }); 127 | 128 | ``` 129 | 130 | ### Coordinate Multiple Components 131 | 132 | Try it [on JSFiddle](http://jsfiddle.net/pushtell/pcutps9q/) 133 | 134 | ```js 135 | 136 | var Experiment = require("react-ab-test/lib/Experiment"); 137 | var Variant = require("react-ab-test/lib/Variant"); 138 | var emitter = require("react-ab-test/lib/emitter"); 139 | 140 | // Define variants in advance. 141 | emitter.defineVariants("My Example", ["A", "B", "C"]); 142 | 143 | var Component1 = React.createClass({ 144 | render: function(){ 145 | return 146 | 147 |
Section A
148 |
149 | 150 |
Section B
151 |
152 |
; 153 | } 154 | }); 155 | 156 | var Component2 = React.createClass({ 157 | render: function(){ 158 | return 159 | 160 |
Subsection A
161 |
162 | 163 |
Subsection B
164 |
165 | 166 |
Subsection C
167 |
168 |
; 169 | } 170 | }); 171 | 172 | var Component3 = React.createClass({ 173 | onButtonClick: function(e){ 174 | emitter.emitWin("My Example"); 175 | }, 176 | render: function(){ 177 | return ; 178 | } 179 | }); 180 | 181 | var App = React.createClass({ 182 | render: function(){ 183 | return
184 | 185 | 186 | 187 |
; 188 | } 189 | }); 190 | 191 | // Called when the experiment is displayed to the user. 192 | emitter.addPlayListener(function(experimentName, variantName){ 193 | console.log("Displaying experiment ‘" + experimentName + "’ variant ‘" + variantName + "’"); 194 | }); 195 | 196 | // Called when a 'win' is emitted, in this case by emitter.emitWin() 197 | emitter.addWinListener(function(experimentName, variantName){ 198 | console.log("Variant ‘" + variantName + "’ of experiment ‘" + experimentName + "’ was clicked"); 199 | }); 200 | 201 | ``` 202 | 203 | ### Weighting Variants 204 | 205 | Try it [on JSFiddle](http://jsfiddle.net/pushtell/e2q7xe4f/) 206 | 207 | Use [emitter.defineVariants()](#emitterdefinevariantsexperimentname-variantnames--variantweights) to optionally define the ratios by which variants are chosen. 208 | 209 | ```js 210 | 211 | var Experiment = require("react-ab-test/lib/Experiment"); 212 | var Variant = require("react-ab-test/lib/Variant"); 213 | var emitter = require("react-ab-test/lib/emitter"); 214 | 215 | // Define variants and weights in advance. 216 | emitter.defineVariants("My Example", ["A", "B", "C"], [10, 40, 40]); 217 | 218 | var App = React.createClass({ 219 | render: function(){ 220 | return
221 | 222 | 223 |
Section A
224 |
225 | 226 |
Section B
227 |
228 | 229 |
Section C
230 |
231 |
232 |
; 233 | } 234 | }); 235 | 236 | ``` 237 | 238 | ### Debugging 239 | 240 | The [debugger](#experimentdebugger) attaches a fixed-position panel to the bottom of the `` element that displays mounted experiments and enables the user to change active variants in real-time. 241 | 242 | The debugger is wrapped in a conditional `if(process.env.NODE_ENV === "production") {...}` and will not display on production builds using [envify](https://github.com/hughsk/envify). 243 | 244 | 245 | 246 | Try it [on JSFiddle](http://jsfiddle.net/pushtell/vs9kkxLd/) 247 | 248 | ```js 249 | 250 | var Experiment = require("react-ab-test/lib/Experiment"); 251 | var Variant = require("react-ab-test/lib/Variant"); 252 | var experimentDebugger = require("react-ab-test/lib/debugger"); 253 | 254 | experimentDebugger.enable(); 255 | 256 | var App = React.createClass({ 257 | render: function(){ 258 | return
259 | 260 | 261 |
Section A
262 |
263 | 264 |
Section B
265 |
266 |
267 |
; 268 | } 269 | }); 270 | 271 | ``` 272 | ### Server Rendering 273 | 274 | A [``](#experiment-) with a `userIdentifier` property will choose a consistent [``](#variant-) suitable for server side rendering. 275 | 276 | See [`./examples/isomorphic`](https://github.com/pushtell/react-ab-test/tree/develop/examples/isomorphic) for a working example. 277 | 278 | #### Example 279 | 280 | The component in [`Component.jsx`](https://github.com/pushtell/react-ab-test/blob/master/examples/isomorphic/Component.jsx): 281 | 282 | ```js 283 | 284 | var React = require("react"); 285 | var Experiment = require("react-ab-test/lib/Experiment"); 286 | var Variant = require("react-ab-test/lib/Variant"); 287 | 288 | module.exports = React.createClass({ 289 | propTypes: { 290 | userIdentifier: React.PropTypes.string.isRequired 291 | }, 292 | render: function(){ 293 | return
294 | 295 | 296 |
Section A
297 |
298 | 299 |
Section B
300 |
301 |
302 |
; 303 | } 304 | }); 305 | 306 | ``` 307 | 308 | We use a session ID for the `userIdentifier` property in this example, although a long-lived user ID would be preferable. See [`server.js`](https://github.com/pushtell/react-ab-test/blob/master/examples/isomorphic/server.js): 309 | 310 | ```js 311 | require("babel/register")({only: /jsx/}); 312 | 313 | var express = require('express'); 314 | var session = require('express-session'); 315 | var React = require("react"); 316 | var ReactDOMServer = require("react-dom/server"); 317 | var Component = require("./Component.jsx"); 318 | var abEmitter = require("react-ab-test/lib/emitter") 319 | 320 | var app = express(); 321 | 322 | app.set('view engine', 'ejs'); 323 | 324 | app.use(session({ 325 | secret: 'keyboard cat', 326 | resave: false, 327 | saveUninitialized: true 328 | })); 329 | 330 | app.get('/', function (req, res) { 331 | var reactElement = React.createElement(Component, {userIdentifier: req.sessionID}); 332 | var reactString = ReactDOMServer.renderToString(reactElement); 333 | abEmitter.rewind(); 334 | res.render('template', { 335 | sessionID: req.sessionID, 336 | reactOutput: reactString 337 | }); 338 | }); 339 | 340 | app.use(express.static('www')); 341 | 342 | app.listen(8080); 343 | ``` 344 | 345 | Remember to call `abEmitter.rewind()` to prevent memory leaks. 346 | 347 | An [EJS](https://github.com/mde/ejs) template in [`template.ejs`](https://github.com/pushtell/react-ab-test/blob/master/examples/isomorphic/views/template.ejs): 348 | 349 | ```html 350 | 351 | 352 | 353 | Isomorphic Rendering Example 354 | 355 | 358 | 359 |
<%- reactOutput %>
360 | 361 | 362 | 363 | ``` 364 | 365 | On the client in [`app.jsx`](https://github.com/pushtell/react-ab-test/blob/master/examples/isomorphic/www/app.jsx): 366 | 367 | ```js 368 | var React = require('react'); 369 | var ReactDOM = require('react-dom'); 370 | var Component = require("../Component.jsx"); 371 | 372 | var container = document.getElementById("react-mount"); 373 | 374 | ReactDOM.render(, container); 375 | ``` 376 | 377 | ### With Babel 378 | 379 | Code from [`./src`](https://github.com/pushtell/react-ab-test/tree/master/src) is written in [JSX](https://facebook.github.io/jsx/) and transpiled into [`./lib`](https://github.com/pushtell/react-ab-test/tree/master/lib) using [Babel](https://babeljs.io/). If your project uses Babel you may want to include files from [`./src`](https://github.com/pushtell/react-ab-test/tree/master/src) directly. 380 | 381 | ## Alternative Libraries 382 | * [**react-experiments**](https://github.com/HubSpot/react-experiments) - “A JavaScript library that assists in defining and managing UI experiments in React” by [Hubspot](https://github.com/HubSpot). Uses Facebook's [PlanOut framework](http://facebook.github.io/planout/) via [Hubspot's javascript port](https://github.com/HubSpot/PlanOut.js). 383 | * [**react-ab**](https://github.com/olahol/react-ab) - “Simple declarative and universal A/B testing component for React” by [Ola Holmström](https://github.com/olahol) 384 | * [**react-native-ab**](https://github.com/lwansbrough/react-native-ab/) - “A component for rendering A/B tests in React Native” by [Loch Wansbrough](https://github.com/lwansbrough) 385 | 386 | Please [let us know](https://github.com/pushtell/react-ab-test/issues/new) about alternate libraries not included here. 387 | 388 | ## Resources for A/B Testing with React 389 | 390 | * [Product Experimentation with React and PlanOut](http://product.hubspot.com/blog/product-experimentation-with-planout-and-react.js) on the [HubSpot Product Blog](http://product.hubspot.com/) 391 | * [Roll Your Own A/B Tests With Optimizely and React](http://engineering.tilt.com/roll-your-own-ab-tests-with-optimizely-and-react/) on the [Tilt Engineering Blog](http://engineering.tilt.com/) 392 | * [Simple Sequential A/B Testing](http://www.evanmiller.org/sequential-ab-testing.html) 393 | * [A/B Testing Rigorously (without losing your job)](http://elem.com/~btilly/ab-testing-multiple-looks/part1-rigorous.html) 394 | 395 | Please [let us know](https://github.com/pushtell/react-ab-test/issues/new) about React A/B testing resources not included here. 396 | 397 | ## API Reference 398 | 399 | ### `` 400 | 401 | Experiment container component. Children must be of type [`Variant`](#variant-). 402 | 403 | * **Properties:** 404 | * `name` - Name of the experiment. 405 | * **Required** 406 | * **Type:** `string` 407 | * **Example:** `"My Example"` 408 | * `userIdentifier` - Distinct user identifier. When defined, this value is hashed to choose a variant if `defaultVariantName` or a stored value is not present. Useful for [server side rendering](#server-rendering). 409 | * **Optional** 410 | * **Type:** `string` 411 | * **Example:** `"7cf61a4521f24507936a8977e1eee2d4"` 412 | * `defaultVariantName` - Name of the default variant. When defined, this value is used to choose a variant if a stored value is not present. This property may be useful for [server side rendering](#server-rendering) but is otherwise not recommended. 413 | * **Optional** 414 | * **Type:** `string` 415 | * **Example:** `"A"` 416 | 417 | ### `` 418 | 419 | Variant container component. 420 | 421 | * **Properties:** 422 | * `name` - Name of the variant. 423 | * **Required** 424 | * **Type:** `string` 425 | * **Example:** `"A"` 426 | 427 | ### `emitter` 428 | 429 | Event emitter responsible for coordinating and reporting usage. Extended from [facebook/emitter](https://github.com/facebook/emitter). 430 | 431 | #### `emitter.emitWin(experimentName)` 432 | 433 | Emit a win event. 434 | 435 | * **Return Type:** No return value 436 | * **Parameters:** 437 | * `experimentName` - Name of an experiment. 438 | * **Required** 439 | * **Type:** `string` 440 | * **Example:** `"My Example"` 441 | 442 | #### `emitter.addActiveVariantListener([experimentName, ] callback)` 443 | 444 | Listen for the active variant specified by an experiment. 445 | 446 | * **Return Type:** [`Subscription`](#subscription) 447 | * **Parameters:** 448 | * `experimentName` - Name of an experiment. If provided, the callback will only be called for the specified experiment. 449 | * **Optional** 450 | * **Type:** `string` 451 | * **Example:** `"My Example"` 452 | * `callback` - Function to be called when a variant is chosen. 453 | * **Required** 454 | * **Type:** `function` 455 | * **Callback Arguments:** 456 | * `experimentName` - Name of the experiment. 457 | * **Type:** `string` 458 | * `variantName` - Name of the variant. 459 | * **Type:** `string` 460 | 461 | #### `emitter.addPlayListener([experimentName, ] callback)` 462 | 463 | Listen for an experiment being displayed to the user. Trigged by the [React componentWillMount lifecycle method](https://facebook.github.io/react/docs/component-specs.html#mounting-componentwillmount). 464 | 465 | * **Return Type:** [`Subscription`](#subscription) 466 | * **Parameters:** 467 | * `experimentName` - Name of an experiment. If provided, the callback will only be called for the specified experiment. 468 | * **Optional** 469 | * **Type:** `string` 470 | * **Example:** `"My Example"` 471 | * `callback` - Function to be called when an experiment is displayed to the user. 472 | * **Required** 473 | * **Type:** `function` 474 | * **Callback Arguments:** 475 | * `experimentName` - Name of the experiment. 476 | * **Type:** `string` 477 | * `variantName` - Name of the variant. 478 | * **Type:** `string` 479 | 480 | #### `emitter.addWinListener([experimentName, ] callback)` 481 | 482 | Listen for a successful outcome from the experiment. Trigged by the [emitter.emitWin(experimentName)](#emitteremitwinexperimentname) method. 483 | 484 | * **Return Type:** [`Subscription`](#subscription) 485 | * **Parameters:** 486 | * `experimentName` - Name of an experiment. If provided, the callback will only be called for the specified experiment. 487 | * **Optional** 488 | * **Type:** `string` 489 | * **Example:** `"My Example"` 490 | * `callback` - Function to be called when a win is emitted. 491 | * **Required** 492 | * **Type:** `function` 493 | * **Callback Arguments:** 494 | * `experimentName` - Name of the experiment. 495 | * **Type:** `string` 496 | * `variantName` - Name of the variant. 497 | * **Type:** `string` 498 | 499 | #### `emitter.defineVariants(experimentName, variantNames [, variantWeights])` 500 | 501 | Define experiment variant names and weighting. Required when an experiment [spans multiple components](#coordinate-multiple-components) containing different sets of variants. 502 | 503 | If `variantWeights` are not specified variants will be chosen at equal rates. 504 | 505 | The variants will be chosen according to the ratio of the numbers, for example variants `["A", "B", "C"]` with weights `[20, 40, 40]` will be chosen 20%, 40%, and 40% of the time, respectively. 506 | 507 | * **Return Type:** No return value 508 | * **Parameters:** 509 | * `experimentName` - Name of the experiment. 510 | * **Required** 511 | * **Type:** `string` 512 | * **Example:** `"My Example"` 513 | * `variantNames` - Array of variant names. 514 | * **Required** 515 | * **Type:** `Array.` 516 | * **Example:** `["A", "B", "C"]` 517 | * `variantWeights` - Array of variant weights. 518 | * **Optional** 519 | * **Type:** `Array.` 520 | * **Example:** `[20, 40, 40]` 521 | 522 | #### `emitter.setActiveVariant(experimentName, variantName)` 523 | 524 | Set the active variant of an experiment. 525 | 526 | * **Return Type:** No return value 527 | * **Parameters:** 528 | * `experimentName` - Name of the experiment. 529 | * **Required** 530 | * **Type:** `string` 531 | * **Example:** `"My Example"` 532 | * `variantName` - Name of the variant. 533 | * **Required** 534 | * **Type:** `string` 535 | * **Example:** `"A"` 536 | 537 | #### `emitter.getActiveVariant(experimentName)` 538 | 539 | Returns the variant name currently displayed by the experiment. 540 | 541 | * **Return Type:** `string` 542 | * **Parameters:** 543 | * `experimentName` - Name of the experiment. 544 | * **Required** 545 | * **Type:** `string` 546 | * **Example:** `"My Example"` 547 | 548 | #### `emitter.getSortedVariants(experimentName)` 549 | 550 | Returns a sorted array of variant names associated with the experiment. 551 | 552 | * **Return Type:** `Array.` 553 | * **Parameters:** 554 | * `experimentName` - Name of the experiment. 555 | * **Required** 556 | * **Type:** `string` 557 | * **Example:** `"My Example"` 558 | 559 | ### `Subscription` 560 | 561 | Returned by the emitter's add listener methods. More information available in the [facebook/emitter documentation.](https://github.com/facebook/emitter#api-concepts) 562 | 563 | #### `subscription.remove()` 564 | 565 | Removes the listener subscription and prevents future callbacks. 566 | 567 | * **Parameters:** No parameters 568 | 569 | ### `experimentDebugger` 570 | 571 | Debugging tool. Attaches a fixed-position panel to the bottom of the `` element that displays mounted experiments and enables the user to change active variants in real-time. 572 | 573 | The debugger is wrapped in a conditional `if(process.env.NODE_ENV === "production") {...}` and will not display on production builds using [envify](https://github.com/hughsk/envify). 574 | 575 | 576 | 577 | #### `experimentDebugger.enable()` 578 | 579 | Attaches the debugging panel to the `` element. 580 | 581 | * **Return Type:** No return value 582 | 583 | #### `experimentDebugger.disable()` 584 | 585 | Removes the debugging panel from the `` element. 586 | 587 | * **Return Type:** No return value 588 | 589 | ### `mixpanelHelper` 590 | 591 | Sends events to [Mixpanel](https://mixpanel.com). Requires `window.mixpanel` to be set using [Mixpanel's embed snippet](https://mixpanel.com/help/reference/javascript). 592 | 593 | #### Usage 594 | 595 | When the [``](#experiment-) is mounted, the helper sends an `Experiment Play` event using [`mixpanel.track(...)`](https://mixpanel.com/help/reference/javascript-full-api-reference#mixpanel.track) with `Experiment` and `Variant` properties. 596 | 597 | When a [win is emitted](#emitteremitwinexperimentname) the helper sends an `Experiment Win` event using [`mixpanel.track(...)`](https://mixpanel.com/help/reference/javascript-full-api-reference#mixpanel.track) with `Experiment` and `Variant` properties. 598 | 599 | Try it [on JSFiddle](https://jsfiddle.net/pushtell/hwtnzm35/) 600 | 601 | ```js 602 | 603 | var Experiment = require("react-ab-test/lib/Experiment"); 604 | var Variant = require("react-ab-test/lib/Variant"); 605 | var mixpanelHelper = require("react-ab-test/lib/helpers/mixpanel"); 606 | 607 | // window.mixpanel has been set by Mixpanel's embed snippet. 608 | mixpanelHelper.enable(); 609 | 610 | var App = React.createClass({ 611 | onButtonClick: function(e){ 612 | emitter.emitWin("My Example"); 613 | // mixpanelHelper sends the 'Experiment Win' event, equivalent to: 614 | // mixpanel.track('Experiment Win', {Experiment: "My Example", Variant: "A"}) 615 | }, 616 | componentWillMount: function(){ 617 | // mixpanelHelper sends the 'Experiment Play' event, equivalent to: 618 | // mixpanel.track('Experiment Play', {Experiment: "My Example", Variant: "A"}) 619 | }, 620 | render: function(){ 621 | return
622 | 623 | 624 |
Section A
625 |
626 | 627 |
Section B
628 |
629 |
630 | 631 |
; 632 | } 633 | }); 634 | 635 | ``` 636 | 637 | #### `mixpanelHelper.enable()` 638 | 639 | Add listeners to `win` and `play` events and report results to Mixpanel. 640 | 641 | * **Return Type:** No return value 642 | 643 | #### `mixpanelHelper.disable()` 644 | 645 | Remove `win` and `play` listeners and stop reporting results to Mixpanel. 646 | 647 | * **Return Type:** No return value 648 | 649 | ### `segmentHelper` 650 | 651 | Sends events to [Segment](https://segment.com). Requires `window.analytics` to be set using [Segment's embed snippet](https://segment.com/docs/libraries/analytics.js/quickstart/#step-1-copy-the-snippet). 652 | 653 | #### Usage 654 | 655 | When the [``](#experiment-) is mounted, the helper sends an `Experiment Viewed` event using [`segment.track(...)`](https://segment.com/docs/libraries/analytics.js/#track) with `experimentName` and `variationName` properties. 656 | 657 | When a [win is emitted](#emitteremitwinexperimentname) the helper sends an `Experiment Won` event using [`segment.track(...)`](https://segment.com/docs/libraries/analytics.js/#track) with `experimentName` and `variationName` properties. 658 | 659 | Try it [on JSFiddle](https://jsfiddle.net/pushtell/ae1jeo2k/) 660 | 661 | ```js 662 | 663 | var Experiment = require("react-ab-test/lib/Experiment"); 664 | var Variant = require("react-ab-test/lib/Variant"); 665 | var segmentHelper = require("react-ab-test/lib/helpers/segment"); 666 | 667 | // window.analytics has been set by Segment's embed snippet. 668 | segmentHelper.enable(); 669 | 670 | var App = React.createClass({ 671 | onButtonClick: function(e){ 672 | emitter.emitWin("My Example"); 673 | // segmentHelper sends the 'Experiment Won' event, equivalent to: 674 | // segment.track('Experiment Won', {experimentName: "My Example", variationName: "A"}) 675 | }, 676 | componentWillMount: function(){ 677 | // segmentHelper sends the 'Experiment Viewed' event, equivalent to: 678 | // segment.track('Experiment Viewed, {experimentName: "My Example", variationName: "A"}) 679 | }, 680 | render: function(){ 681 | return
682 | 683 | 684 |
Section A
685 |
686 | 687 |
Section B
688 |
689 |
690 | 691 |
; 692 | } 693 | }); 694 | 695 | ``` 696 | 697 | #### `segmentHelper.enable()` 698 | 699 | Add listeners to `win` and `play` events and report results to Segment. 700 | 701 | * **Return Type:** No return value 702 | 703 | #### `segmentHelper.disable()` 704 | 705 | Remove `win` and `play` listeners and stop reporting results to Segment. 706 | 707 | * **Return Type:** No return value 708 | 709 | ## How to contribute 710 | ### Requisites 711 | Before contribuiting you need: 712 | - [doctoc](https://github.com/thlorenz/doctoc) installed 713 | 714 | Then you can: 715 | - Apply your changes :sunglasses: 716 | - Build your changes with `npm run build` 717 | - Test your changes with `npm test` 718 | - Lint your changes with `npm run lint` 719 | - And finally open the PR! :tada: 720 | 721 | ### Browser Coverage 722 | [Karma](http://karma-runner.github.io/0.13/index.html) tests are performed on [Browserstack](https://www.browserstack.com/) in the following browsers: 723 | 724 | * IE 9, Windows 7 725 | * IE 10, Windows 7 726 | * IE 11, Windows 7 727 | * Opera (latest version), Windows 7 728 | * Firefox (latest version), Windows 7 729 | * Chrome (latest version), Windows 7 730 | * Safari (latest version), OSX Yosemite 731 | * Android Browser (latest version), Google Nexus 7, Android 4.1 732 | * Mobile Safari (latest version), iPhone 6, iOS 8.3 733 | 734 | [Mocha](https://mochajs.org/) tests are performed on the latest version of [Node](https://nodejs.org/en/). 735 | 736 | Please [let us know](https://github.com/pushtell/react-ab-test/issues/new) if a different configuration should be included here. 737 | 738 | ### Running Tests 739 | 740 | Locally: 741 | 742 | ```bash 743 | 744 | npm test 745 | 746 | ``` 747 | 748 | On [Browserstack](https://www.browserstack.com/): 749 | 750 | ```bash 751 | 752 | BROWSERSTACK_USERNAME=YOUR_USERNAME BROWSERSTACK_ACCESS_KEY=YOUR_ACCESS_KEY npm test 753 | 754 | ``` 755 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | general: 2 | branches: 3 | only: 4 | - master -------------------------------------------------------------------------------- /documentation-images/debugger-animated-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pushtell/react-ab-test/b52e06e8d6c90b21f6fb14ca98cd06b514f5b737/documentation-images/debugger-animated-2.gif -------------------------------------------------------------------------------- /examples/browser/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Table of Contents

4 | 5 | - [Running this example](#running-this-example) 6 | 7 | 8 | 9 | ### Running this example 10 | 11 | 1. Clone the [react-ab-test repository](https://github.com/pushtell/react-ab-test) 12 | 2. Change to the [./example/browser](https://github.com/pushtell/react-ab-test/tree/master/example/browser) directory 13 | 2. Run `npm install` to install dependencies 14 | 3. Run `npm start` 15 | 4. Open [http://localhost:8080](http://localhost:8080) in your browser -------------------------------------------------------------------------------- /examples/browser/app.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var Experiment = require("../../lib/Experiment"); 4 | var Variant = require("../../lib/Variant"); 5 | var experimentDebugger = require("../../lib/debugger"); 6 | 7 | experimentDebugger.enable(); 8 | 9 | var App = React.createClass({ 10 | render() { 11 | return
12 |

Experiment 1

13 | 14 | 15 |

Variant A

16 |
17 | 18 |

Variant B

19 |
20 |
21 |

Experiment 2

22 | 23 | 24 |

Variant X

25 |
26 | 27 |

Variant Y

28 |
29 |
30 |
; 31 | } 32 | }); 33 | 34 | ReactDOM.render(, document.getElementById('react')); 35 | 36 | 37 | -------------------------------------------------------------------------------- /examples/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React A/B Test Example 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-browser", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "dependencies": { 7 | "react": "^0.14.0", 8 | "react-dom": "^0.14.0", 9 | "webpack-dev-server": "*" 10 | }, 11 | "devDependencies": {}, 12 | "scripts": { 13 | "start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --config ./webpack.example.config.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/pushtell/react-ab-test.git" 18 | }, 19 | "author": "", 20 | "license": "MIT" 21 | } 22 | -------------------------------------------------------------------------------- /examples/browser/webpack.example.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | entry: { 6 | index: path.resolve(__dirname, './app.jsx') 7 | }, 8 | output: { 9 | path: __dirname, 10 | filename: 'bundle.js' 11 | }, 12 | resolve: { 13 | extensions: ['', '.js', '.jsx'] 14 | }, 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.jsx?$/, 19 | exclude: /(node_modules)/, 20 | loader: 'babel', 21 | query: { 22 | cacheDirectory: true, 23 | presets: ["stage-1", "es2015", "react"] 24 | } 25 | }, { 26 | exclude: /node_modules/, 27 | loader: 'regenerator-loader', 28 | test: /\.jsx$/ 29 | } 30 | ], 31 | postLoaders: [ 32 | { 33 | loader: "transform?envify" 34 | } 35 | ], 36 | plugins: [ 37 | new webpack.optimize.UglifyJsPlugin() 38 | ] 39 | }, 40 | devtool: 'inline-source-map' 41 | }; -------------------------------------------------------------------------------- /examples/isomorphic/Component.jsx: -------------------------------------------------------------------------------- 1 | var React = require("react"); 2 | var Experiment = require("../../lib/Experiment"); 3 | var Variant = require("../../lib/Variant"); 4 | 5 | module.exports = React.createClass({ 6 | propTypes: { 7 | userIdentifier: React.PropTypes.string.isRequired 8 | }, 9 | render: function(){ 10 | return
11 | 12 | 13 |
Section A
14 |
15 | 16 |
Section B
17 |
18 |
19 |
; 20 | } 21 | }); -------------------------------------------------------------------------------- /examples/isomorphic/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Table of Contents

4 | 5 | - [Running this example](#running-this-example) 6 | 7 | 8 | 9 | ### Running this example 10 | 11 | 1. Clone the [react-ab-test repository](https://github.com/pushtell/react-ab-test) 12 | 2. Change to the [./example/isomorphic](https://github.com/pushtell/react-ab-test/tree/master/example/isomorphic) directory 13 | 2. Run `npm install` to install dependencies 14 | 3. Run `npm start` 15 | 4. Open [http://localhost:8080](http://localhost:8080) in your browser 16 | -------------------------------------------------------------------------------- /examples/isomorphic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-isomorphic", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "dependencies": { 7 | "express": "^4.13.3", 8 | "envify": "^3.4.0", 9 | "express-session": "^1.11.3", 10 | "react": "^0.14.0", 11 | "react-dom": "^0.14.0", 12 | "webpack": "^1.12.2", 13 | "ejs": "^2.3.4" 14 | }, 15 | "devDependencies": {}, 16 | "scripts": { 17 | "start": "webpack --config webpack.example.config.js; node server.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/pushtell/react-ab-test.git" 22 | }, 23 | "author": "", 24 | "license": "MIT" 25 | } 26 | -------------------------------------------------------------------------------- /examples/isomorphic/server.js: -------------------------------------------------------------------------------- 1 | require("babel-core/register")({only: /Component|www/}); 2 | 3 | var express = require('express'); 4 | var session = require('express-session'); 5 | var React = require("react"); 6 | var ReactDOMServer = require("react-dom/server"); 7 | var Component = require("./Component.jsx"); 8 | var abTestsEmitter = require("../../lib/emitter"); 9 | 10 | var app = express(); 11 | 12 | app.set('view engine', 'ejs'); 13 | 14 | app.use(session({ 15 | secret: 'keyboard cat', 16 | resave: false, 17 | saveUninitialized: true 18 | })); 19 | 20 | app.get('/', function (req, res) { 21 | var reactElement = React.createElement(Component, {userIdentifier: req.sessionID}); 22 | var reactString = ReactDOMServer.renderToString(reactElement); 23 | 24 | // important to prevent memory leaks 25 | abTestsEmitter.rewind(); 26 | res.render('template', { 27 | sessionID: req.sessionID, 28 | reactOutput: reactString 29 | }); 30 | }); 31 | 32 | app.use(express.static('www')); 33 | 34 | app.listen(8080); 35 | -------------------------------------------------------------------------------- /examples/isomorphic/views/template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Isomorphic Rendering Example 5 | 6 | 9 | 10 |
<%- reactOutput %>
11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/isomorphic/webpack.example.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | entry: { 6 | index: path.resolve(__dirname, './www/app.jsx') 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, './www'), 10 | filename: 'bundle.js' 11 | }, 12 | resolve: { 13 | extensions: ['', '.js', '.jsx'] 14 | }, 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.jsx?$/, 19 | exclude: /(node_modules)/, 20 | loader: 'babel', 21 | query: { 22 | cacheDirectory: true, 23 | presets: ["stage-1", "es2015", "react"] 24 | } 25 | } 26 | ], 27 | postLoaders: [ 28 | { 29 | loader: "transform?envify" 30 | } 31 | ] 32 | }, 33 | devtool: 'inline-source-map' 34 | }; -------------------------------------------------------------------------------- /examples/isomorphic/www/app.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var Component = require("../Component.jsx"); 4 | 5 | var container = document.getElementById("react-mount"); 6 | 7 | ReactDOM.render(, container); 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Experiment: require("./lib/Experiment"), 3 | Variant: require("./lib/Variant"), 4 | emitter: require("./lib/emitter"), 5 | experimentDebugger: require("./lib/debugger"), 6 | mixpanelHelper: require("./lib/helpers/mixpanel"), 7 | segmentHelper: require("./lib/helpers/segment") 8 | }; 9 | -------------------------------------------------------------------------------- /lib/CoreExperiment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | var _propTypes = require('prop-types'); 14 | 15 | var _propTypes2 = _interopRequireDefault(_propTypes); 16 | 17 | var _warning = require('fbjs/lib/warning'); 18 | 19 | var _warning2 = _interopRequireDefault(_warning); 20 | 21 | var _emitter = require('./emitter'); 22 | 23 | var _emitter2 = _interopRequireDefault(_emitter); 24 | 25 | var _Variant = require('./Variant'); 26 | 27 | var _Variant2 = _interopRequireDefault(_Variant); 28 | 29 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 30 | 31 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 32 | 33 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 34 | 35 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 36 | 37 | var CoreExperiment = function (_Component) { 38 | _inherits(CoreExperiment, _Component); 39 | 40 | function CoreExperiment(props) { 41 | _classCallCheck(this, CoreExperiment); 42 | 43 | var _this = _possibleConstructorReturn(this, (CoreExperiment.__proto__ || Object.getPrototypeOf(CoreExperiment)).call(this)); 44 | 45 | _this.win = function () { 46 | _emitter2.default.emitWin(_this.props.name); 47 | }; 48 | 49 | _this.state = {}; 50 | _this.displayName = "Pushtell.CoreExperiment"; 51 | 52 | 53 | var children = {}; 54 | _react2.default.Children.forEach(props.children, function (element) { 55 | if (!_react2.default.isValidElement(element) || element.type.displayName !== "Pushtell.Variant") { 56 | console.log(element.type); 57 | var error = new Error("Pushtell Experiment children must be Pushtell Variant components."); 58 | error.type = "PUSHTELL_INVALID_CHILD"; 59 | throw error; 60 | } 61 | children[element.props.name] = element; 62 | _emitter2.default.addExperimentVariant(props.name, element.props.name); 63 | }); 64 | _emitter2.default.emit("variants-loaded", props.name); 65 | _this.state.variants = children; 66 | return _this; 67 | } 68 | 69 | _createClass(CoreExperiment, [{ 70 | key: 'componentWillReceiveProps', 71 | value: function componentWillReceiveProps(nextProps) { 72 | if (nextProps.value !== this.props.value || nextProps.children !== this.props.children) { 73 | var value = typeof nextProps.value === "function" ? nextProps.value() : nextProps.value; 74 | var children = {}; 75 | _react2.default.Children.forEach(nextProps.children, function (element) { 76 | children[element.props.name] = element; 77 | }); 78 | this.setState({ 79 | value: value, 80 | variants: children 81 | }); 82 | } 83 | } 84 | }, { 85 | key: 'componentWillMount', 86 | value: function componentWillMount() { 87 | var _this2 = this; 88 | 89 | var value = typeof this.props.value === "function" ? this.props.value() : this.props.value; 90 | if (!this.state.variants[value]) { 91 | if ("production" !== process.env.NODE_ENV) { 92 | (0, _warning2.default)(true, 'Experiment “' + this.props.name + '” does not contain variant “' + value + '”'); 93 | } 94 | } 95 | _emitter2.default._incrementActiveExperiments(this.props.name); 96 | _emitter2.default.setActiveVariant(this.props.name, value); 97 | _emitter2.default._emitPlay(this.props.name, value); 98 | this.setState({ 99 | value: value 100 | }); 101 | this.valueSubscription = _emitter2.default.addActiveVariantListener(this.props.name, function (experimentName, variantName) { 102 | _this2.setState({ 103 | value: variantName 104 | }); 105 | }); 106 | } 107 | }, { 108 | key: 'componentWillUnmount', 109 | value: function componentWillUnmount() { 110 | _emitter2.default._decrementActiveExperiments(this.props.name); 111 | this.valueSubscription.remove(); 112 | } 113 | }, { 114 | key: 'render', 115 | value: function render() { 116 | return this.state.variants[this.state.value] || null; 117 | } 118 | }]); 119 | 120 | return CoreExperiment; 121 | }(_react.Component); 122 | 123 | CoreExperiment.propTypes = { 124 | name: _propTypes2.default.string.isRequired, 125 | value: _propTypes2.default.oneOfType([_propTypes2.default.string, _propTypes2.default.func]).isRequired 126 | }; 127 | exports.default = CoreExperiment; 128 | ; 129 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/Experiment.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 10 | 11 | var _react = require("react"); 12 | 13 | var _react2 = _interopRequireDefault(_react); 14 | 15 | var _propTypes = require("prop-types"); 16 | 17 | var _propTypes2 = _interopRequireDefault(_propTypes); 18 | 19 | var _CoreExperiment = require("./CoreExperiment"); 20 | 21 | var _CoreExperiment2 = _interopRequireDefault(_CoreExperiment); 22 | 23 | var _emitter = require("./emitter"); 24 | 25 | var _emitter2 = _interopRequireDefault(_emitter); 26 | 27 | var _crc = require("fbjs/lib/crc32"); 28 | 29 | var _crc2 = _interopRequireDefault(_crc); 30 | 31 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 32 | 33 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 34 | 35 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 36 | 37 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 38 | 39 | var store = void 0; 40 | 41 | var noopStore = { 42 | getItem: function getItem() {}, 43 | setItem: function setItem() {} 44 | }; 45 | 46 | if (typeof window !== 'undefined' && 'localStorage' in window && window['localStorage'] !== null) { 47 | try { 48 | var key = '__pushtell_react__'; 49 | window.localStorage.setItem(key, key); 50 | if (window.localStorage.getItem(key) !== key) { 51 | store = noopStore; 52 | } else { 53 | window.localStorage.removeItem(key); 54 | store = window.localStorage; 55 | } 56 | } catch (e) { 57 | store = noopStore; 58 | } 59 | } else { 60 | store = noopStore; 61 | } 62 | 63 | _emitter2.default.addActiveVariantListener(function (experimentName, variantName, skipSave) { 64 | if (skipSave) { 65 | return; 66 | } 67 | store.setItem('PUSHTELL-' + experimentName, variantName); 68 | }); 69 | 70 | var Experiment = function (_Component) { 71 | _inherits(Experiment, _Component); 72 | 73 | function Experiment() { 74 | var _ref; 75 | 76 | var _temp, _this, _ret; 77 | 78 | _classCallCheck(this, Experiment); 79 | 80 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 81 | args[_key] = arguments[_key]; 82 | } 83 | 84 | return _ret = (_temp = (_this = _possibleConstructorReturn(this, (_ref = Experiment.__proto__ || Object.getPrototypeOf(Experiment)).call.apply(_ref, [this].concat(args))), _this), _this.win = function () { 85 | _emitter2.default.emitWin(_this.props.name); 86 | }, _this.getRandomValue = function () { 87 | var variants = _emitter2.default.getSortedVariants(_this.props.name); 88 | var weights = _emitter2.default.getSortedVariantWeights(_this.props.name); 89 | var weightSum = weights.reduce(function (a, b) { 90 | return a + b; 91 | }, 0); 92 | var weightedIndex = typeof _this.props.userIdentifier === 'string' ? Math.abs((0, _crc2.default)(_this.props.userIdentifier) % weightSum) : Math.floor(Math.random() * weightSum); 93 | var randomValue = variants[variants.length - 1]; 94 | for (var index = 0; index < weights.length; index++) { 95 | weightedIndex -= weights[index]; 96 | if (weightedIndex < 0) { 97 | randomValue = variants[index]; 98 | break; 99 | } 100 | } 101 | _emitter2.default.setActiveVariant(_this.props.name, randomValue); 102 | return randomValue; 103 | }, _this.getLocalStorageValue = function () { 104 | if (typeof _this.props.userIdentifier === "string") { 105 | return _this.getRandomValue(); 106 | } 107 | var activeValue = _emitter2.default.getActiveVariant(_this.props.name); 108 | if (typeof activeValue === "string") { 109 | return activeValue; 110 | } 111 | var storedValue = store.getItem('PUSHTELL-' + _this.props.name); 112 | if (typeof storedValue === "string") { 113 | _emitter2.default.setActiveVariant(_this.props.name, storedValue, true); 114 | return storedValue; 115 | } 116 | if (typeof _this.props.defaultVariantName === 'string') { 117 | _emitter2.default.setActiveVariant(_this.props.name, _this.props.defaultVariantName); 118 | return _this.props.defaultVariantName; 119 | } 120 | return _this.getRandomValue(); 121 | }, _temp), _possibleConstructorReturn(_this, _ret); 122 | } 123 | 124 | _createClass(Experiment, [{ 125 | key: "render", 126 | value: function render() { 127 | return _react2.default.createElement(_CoreExperiment2.default, _extends({}, this.props, { value: this.getLocalStorageValue })); 128 | } 129 | }]); 130 | 131 | return Experiment; 132 | }(_react.Component); 133 | 134 | Experiment.propTypes = { 135 | name: _propTypes2.default.string.isRequired, 136 | defaultVariantName: _propTypes2.default.string, 137 | userIdentifier: _propTypes2.default.string 138 | }; 139 | Experiment.displayName = "Pushtell.Experiment"; 140 | exports.default = Experiment; 141 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/Variant.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 8 | 9 | var _react = require('react'); 10 | 11 | var _react2 = _interopRequireDefault(_react); 12 | 13 | var _propTypes = require('prop-types'); 14 | 15 | var _propTypes2 = _interopRequireDefault(_propTypes); 16 | 17 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 18 | 19 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 20 | 21 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } 22 | 23 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } 24 | 25 | var Variant = function (_Component) { 26 | _inherits(Variant, _Component); 27 | 28 | function Variant() { 29 | _classCallCheck(this, Variant); 30 | 31 | return _possibleConstructorReturn(this, (Variant.__proto__ || Object.getPrototypeOf(Variant)).apply(this, arguments)); 32 | } 33 | 34 | _createClass(Variant, [{ 35 | key: 'render', 36 | value: function render() { 37 | if (_react2.default.isValidElement(this.props.children)) { 38 | return this.props.children; 39 | } else { 40 | return _react2.default.createElement( 41 | 'span', 42 | null, 43 | this.props.children 44 | ); 45 | } 46 | } 47 | }]); 48 | 49 | return Variant; 50 | }(_react.Component); 51 | 52 | Variant.propTypes = { 53 | name: _propTypes2.default.string.isRequired 54 | }; 55 | Variant.displayName = "Pushtell.Variant"; 56 | exports.default = Variant; 57 | ; 58 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/debugger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _react = require('react'); 4 | 5 | var _react2 = _interopRequireDefault(_react); 6 | 7 | var _reactDom = require('react-dom'); 8 | 9 | var _reactDom2 = _interopRequireDefault(_reactDom); 10 | 11 | var _emitter = require('./emitter'); 12 | 13 | var _emitter2 = _interopRequireDefault(_emitter); 14 | 15 | var _ExecutionEnvironment = require('fbjs/lib/ExecutionEnvironment'); 16 | 17 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 18 | 19 | if (process.env.NODE_ENV === "production" || !_ExecutionEnvironment.canUseDOM) { 20 | module.exports = { 21 | enable: function enable() {}, 22 | disable: function disable() {} 23 | }; 24 | } else { 25 | var attachStyleSheet = function attachStyleSheet() { 26 | style = document.createElement("style"); 27 | style.appendChild(document.createTextNode("")); 28 | document.head.appendChild(style); 29 | function addCSSRule(selector, rules) { 30 | if ("insertRule" in style.sheet) { 31 | style.sheet.insertRule(selector + "{" + rules + "}", 0); 32 | } else if ("addRule" in style.sheet) { 33 | style.sheet.addRule(selector, rules, 0); 34 | } 35 | } 36 | addCSSRule("#pushtell-debugger", "z-index: 25000"); 37 | addCSSRule("#pushtell-debugger", "position: fixed"); 38 | addCSSRule("#pushtell-debugger", "transform: translateX(-50%)"); 39 | addCSSRule("#pushtell-debugger", "bottom: 0"); 40 | addCSSRule("#pushtell-debugger", "left: 50%"); 41 | addCSSRule("#pushtell-debugger ul", "margin: 0"); 42 | addCSSRule("#pushtell-debugger ul", "padding: 0 0 0 20px"); 43 | addCSSRule("#pushtell-debugger li", "margin: 0"); 44 | addCSSRule("#pushtell-debugger li", "padding: 0"); 45 | addCSSRule("#pushtell-debugger li", "font-size: 14px"); 46 | addCSSRule("#pushtell-debugger li", "line-height: 14px"); 47 | addCSSRule("#pushtell-debugger input", "float: left"); 48 | addCSSRule("#pushtell-debugger input", "margin: 0 10px 0 0"); 49 | addCSSRule("#pushtell-debugger input", "padding: 0"); 50 | addCSSRule("#pushtell-debugger input", "cursor: pointer"); 51 | addCSSRule("#pushtell-debugger label", "color: #999999"); 52 | addCSSRule("#pushtell-debugger label", "margin: 0 0 10px 0"); 53 | addCSSRule("#pushtell-debugger label", "cursor: pointer"); 54 | addCSSRule("#pushtell-debugger label", "font-weight: normal"); 55 | addCSSRule("#pushtell-debugger label.active", "color: #000000"); 56 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "font-size: 16px"); 57 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "color: #000000"); 58 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "margin: 0 0 10px 0"); 59 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "font-size: 10px"); 60 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "color: #999999"); 61 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "text-align: center"); 62 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "margin: 10px -40px 0 -10px"); 63 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "border-top: 1px solid #b3b3b3"); 64 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "padding: 10px 10px 5px 10px"); 65 | addCSSRule("#pushtell-debugger .pushtell-handle", "cursor: pointer"); 66 | addCSSRule("#pushtell-debugger .pushtell-handle", "padding: 5px 10px 5px 10px"); 67 | addCSSRule("#pushtell-debugger .pushtell-panel", "padding: 15px 40px 5px 10px"); 68 | addCSSRule("#pushtell-debugger .pushtell-container", "font-size: 11px"); 69 | addCSSRule("#pushtell-debugger .pushtell-container", "background-color: #ebebeb"); 70 | addCSSRule("#pushtell-debugger .pushtell-container", "color: #000000"); 71 | addCSSRule("#pushtell-debugger .pushtell-container", "box-shadow: 0px 0 5px rgba(0, 0, 0, 0.1)"); 72 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top: 1px solid #b3b3b3"); 73 | addCSSRule("#pushtell-debugger .pushtell-container", "border-left: 1px solid #b3b3b3"); 74 | addCSSRule("#pushtell-debugger .pushtell-container", "border-right: 1px solid #b3b3b3"); 75 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top-left-radius: 2px"); 76 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top-right-radius: 2px"); 77 | addCSSRule("#pushtell-debugger .pushtell-close", "cursor: pointer"); 78 | addCSSRule("#pushtell-debugger .pushtell-close", "font-size: 16px"); 79 | addCSSRule("#pushtell-debugger .pushtell-close", "font-weight: bold"); 80 | addCSSRule("#pushtell-debugger .pushtell-close", "color: #CC0000"); 81 | addCSSRule("#pushtell-debugger .pushtell-close", "position: absolute"); 82 | addCSSRule("#pushtell-debugger .pushtell-close", "top: 0px"); 83 | addCSSRule("#pushtell-debugger .pushtell-close", "right: 7px"); 84 | addCSSRule("#pushtell-debugger .pushtell-close:hover", "color: #FF0000"); 85 | addCSSRule("#pushtell-debugger .pushtell-close, #pushtell-debugger label", "transition: all .25s"); 86 | }; 87 | 88 | var removeStyleSheet = function removeStyleSheet() { 89 | if (style !== null) { 90 | document.head.removeChild(style); 91 | style = null; 92 | } 93 | }; 94 | 95 | var style = null; 96 | 97 | var Debugger = _react2.default.createClass({ 98 | displayName: "Pushtell.Debugger", 99 | getInitialState: function getInitialState() { 100 | return { 101 | experiments: _emitter2.default.getActiveExperiments(), 102 | visible: false 103 | }; 104 | }, 105 | toggleVisibility: function toggleVisibility() { 106 | this.setState({ 107 | visible: !this.state.visible 108 | }); 109 | }, 110 | updateExperiments: function updateExperiments() { 111 | this.setState({ 112 | experiments: _emitter2.default.getActiveExperiments() 113 | }); 114 | }, 115 | setActiveVariant: function setActiveVariant(experimentName, variantName) { 116 | _emitter2.default.setActiveVariant(experimentName, variantName); 117 | }, 118 | componentWillMount: function componentWillMount() { 119 | this.activeSubscription = _emitter2.default.addListener("active", this.updateExperiments); 120 | this.inactiveSubscription = _emitter2.default.addListener("inactive", this.updateExperiments); 121 | }, 122 | componentWillUnmount: function componentWillUnmount() { 123 | this.activeSubscription.remove(); 124 | this.inactiveSubscription.remove(); 125 | }, 126 | render: function render() { 127 | var _this = this; 128 | 129 | var experimentNames = Object.keys(this.state.experiments); 130 | if (this.state.visible) { 131 | return _react2.default.createElement( 132 | 'div', 133 | { className: 'pushtell-container pushtell-panel' }, 134 | _react2.default.createElement( 135 | 'div', 136 | { className: 'pushtell-close', onClick: this.toggleVisibility }, 137 | '\xD7' 138 | ), 139 | experimentNames.map(function (experimentName) { 140 | var variantNames = Object.keys(_this.state.experiments[experimentName]); 141 | if (variantNames.length === 0) { 142 | return; 143 | } 144 | return _react2.default.createElement( 145 | 'div', 146 | { className: 'pushtell-experiment', key: experimentName }, 147 | _react2.default.createElement( 148 | 'div', 149 | { className: 'pushtell-experiment-name' }, 150 | experimentName 151 | ), 152 | _react2.default.createElement( 153 | 'ul', 154 | null, 155 | variantNames.map(function (variantName) { 156 | return _react2.default.createElement( 157 | 'li', 158 | { key: variantName }, 159 | _react2.default.createElement( 160 | 'label', 161 | { className: _this.state.experiments[experimentName][variantName] ? "active" : null, onClick: _this.setActiveVariant.bind(_this, experimentName, variantName) }, 162 | _react2.default.createElement('input', { type: 'radio', name: experimentName, value: variantName, defaultChecked: _this.state.experiments[experimentName][variantName] }), 163 | variantName 164 | ) 165 | ); 166 | }) 167 | ) 168 | ); 169 | }), 170 | _react2.default.createElement( 171 | 'div', 172 | { className: 'pushtell-production-build-note' }, 173 | 'This panel is hidden on production builds.' 174 | ) 175 | ); 176 | } else if (experimentNames.length > 0) { 177 | return _react2.default.createElement( 178 | 'div', 179 | { className: 'pushtell-container pushtell-handle', onClick: this.toggleVisibility }, 180 | experimentNames.length, 181 | ' Active Experiment', 182 | experimentNames.length > 1 ? "s" : "" 183 | ); 184 | } else { 185 | return null; 186 | } 187 | } 188 | }); 189 | 190 | module.exports = { 191 | enable: function enable() { 192 | attachStyleSheet(); 193 | var body = document.getElementsByTagName('body')[0]; 194 | var container = document.createElement('div'); 195 | container.id = 'pushtell-debugger'; 196 | body.appendChild(container); 197 | _reactDom2.default.render(_react2.default.createElement(Debugger, null), container); 198 | }, 199 | disable: function disable() { 200 | removeStyleSheet(); 201 | var body = document.getElementsByTagName('body')[0]; 202 | var container = document.getElementById('pushtell-debugger'); 203 | if (container) { 204 | _reactDom2.default.unmountComponentAtNode(container); 205 | body.removeChild(container); 206 | } 207 | } 208 | }; 209 | } -------------------------------------------------------------------------------- /lib/emitter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _fbemitter = require('fbemitter'); 8 | 9 | var values = {}; 10 | var experiments = {}; 11 | var experimentWeights = {}; 12 | var activeExperiments = {}; 13 | var experimentsWithDefinedVariants = {}; 14 | var playedExperiments = {}; 15 | 16 | var emitter = new _fbemitter.EventEmitter(); 17 | 18 | var PushtellEventEmitter = function PushtellEventEmitter() {}; 19 | 20 | PushtellEventEmitter.prototype.emitWin = function (experimentName) { 21 | if (typeof experimentName !== 'string') { 22 | throw new Error("Required argument 'experimentName' should have type 'string'"); 23 | } 24 | emitter.emit("win", experimentName, values[experimentName]); 25 | }; 26 | 27 | PushtellEventEmitter.prototype._emitPlay = function (experimentName, variantName) { 28 | if (typeof experimentName !== 'string') { 29 | throw new Error("Required argument 'experimentName' should have type 'string'"); 30 | } 31 | if (typeof variantName !== 'string') { 32 | throw new Error("Required argument 'variantName' should have type 'string'"); 33 | } 34 | if (!playedExperiments[experimentName]) { 35 | emitter.emit('play', experimentName, variantName); 36 | playedExperiments[experimentName] = true; 37 | } 38 | }; 39 | 40 | PushtellEventEmitter.prototype._resetPlayedExperiments = function () { 41 | values = {}; 42 | playedExperiments = {}; 43 | }; 44 | 45 | PushtellEventEmitter.prototype._reset = function () { 46 | values = {}; 47 | experiments = {}; 48 | experimentWeights = {}; 49 | activeExperiments = {}; 50 | experimentsWithDefinedVariants = {}; 51 | playedExperiments = {}; 52 | }; 53 | 54 | PushtellEventEmitter.prototype.rewind = function () { 55 | this._reset(); 56 | emitter.removeAllListeners(); 57 | }; 58 | 59 | PushtellEventEmitter.prototype._incrementActiveExperiments = function (experimentName) { 60 | activeExperiments[experimentName] = activeExperiments[experimentName] || 0; 61 | activeExperiments[experimentName] += 1; 62 | emitter.emit("active", experimentName); 63 | }; 64 | 65 | PushtellEventEmitter.prototype._decrementActiveExperiments = function (experimentName) { 66 | activeExperiments[experimentName] -= 1; 67 | emitter.emit("inactive", experimentName); 68 | }; 69 | 70 | PushtellEventEmitter.prototype.addActiveVariantListener = function (experimentName, callback) { 71 | if (typeof experimentName === "function") { 72 | callback = experimentName; 73 | return emitter.addListener("active-variant", function (_experimentName, variantName, passthrough) { 74 | callback(_experimentName, variantName, passthrough); 75 | }); 76 | } 77 | return emitter.addListener("active-variant", function (_experimentName, variantName, passthrough) { 78 | if (_experimentName === experimentName) { 79 | callback(_experimentName, variantName, passthrough); 80 | } 81 | }); 82 | }; 83 | 84 | PushtellEventEmitter.prototype.emit = function () { 85 | return emitter.emit.apply(emitter, arguments); 86 | }; 87 | 88 | PushtellEventEmitter.prototype.addListener = function (eventName, callback) { 89 | return emitter.addListener(eventName, callback); 90 | }; 91 | 92 | PushtellEventEmitter.prototype.once = function (eventName, callback) { 93 | return emitter.once(eventName, callback); 94 | }; 95 | 96 | PushtellEventEmitter.prototype.addPlayListener = function (experimentName, callback) { 97 | if (typeof experimentName === "function") { 98 | callback = experimentName; 99 | return emitter.addListener('play', function (_experimentName, variantName) { 100 | callback(_experimentName, variantName); 101 | }); 102 | } 103 | return emitter.addListener('play', function (_experimentName, variantName) { 104 | if (_experimentName === experimentName) { 105 | callback(_experimentName, variantName); 106 | } 107 | }); 108 | }; 109 | 110 | PushtellEventEmitter.prototype.addWinListener = function (experimentName, callback) { 111 | if (typeof experimentName === "function") { 112 | callback = experimentName; 113 | return emitter.addListener('win', function (_experimentName, variantName) { 114 | callback(_experimentName, variantName); 115 | }); 116 | } 117 | return emitter.addListener('win', function (_experimentName, variantName) { 118 | if (_experimentName === experimentName) { 119 | callback(_experimentName, variantName); 120 | } 121 | }); 122 | }; 123 | 124 | PushtellEventEmitter.prototype.defineVariants = function (experimentName, variantNames, variantWeights) { 125 | var variantsNamesMap = {}; 126 | var variantWeightsMap = {}; 127 | variantNames.forEach(function (variantName) { 128 | variantsNamesMap[variantName] = true; 129 | }); 130 | if (Array.isArray(variantWeights)) { 131 | if (variantNames.length !== variantWeights.length) { 132 | throw new Error("Required argument 'variantNames' should have the same number of elements as optional argument 'variantWeights'"); 133 | } 134 | variantNames.forEach(function (variantName, index) { 135 | if (typeof variantWeights[index] !== 'number') { 136 | throw new Error("Optional argument 'variantWeights' should be an array of numbers."); 137 | } 138 | variantWeightsMap[variantName] = variantWeights[index]; 139 | }); 140 | } else { 141 | variantNames.forEach(function (variantName, index) { 142 | variantWeightsMap[variantName] = 1; 143 | }); 144 | } 145 | experimentWeights[experimentName] = variantWeightsMap; 146 | experiments[experimentName] = variantsNamesMap; 147 | experimentsWithDefinedVariants[experimentName] = true; 148 | }; 149 | 150 | PushtellEventEmitter.prototype.getSortedVariants = function (experimentName) { 151 | var variantNames = Object.keys(experiments[experimentName]); 152 | variantNames.sort(); 153 | return variantNames; 154 | }; 155 | 156 | PushtellEventEmitter.prototype.getSortedVariantWeights = function (experimentName) { 157 | return this.getSortedVariants(experimentName).map(function (variantName) { 158 | return experimentWeights[experimentName][variantName]; 159 | }); 160 | }; 161 | 162 | PushtellEventEmitter.prototype.getActiveExperiments = function () { 163 | var response = {}; 164 | Object.keys(activeExperiments).forEach(function (experimentName) { 165 | if (activeExperiments[experimentName] === 0) { 166 | return; 167 | } 168 | response[experimentName] = {}; 169 | Object.keys(experiments[experimentName]).forEach(function (variantName) { 170 | response[experimentName][variantName] = values[experimentName] === variantName; 171 | }); 172 | }); 173 | return response; 174 | }; 175 | 176 | PushtellEventEmitter.prototype.getActiveVariant = function (experimentName) { 177 | return values[experimentName]; 178 | }; 179 | 180 | PushtellEventEmitter.prototype.setActiveVariant = function (experimentName, variantName, passthrough) { 181 | values[experimentName] = variantName; 182 | emitter.emit("active-variant", experimentName, variantName, passthrough); 183 | }; 184 | 185 | PushtellEventEmitter.prototype.addExperimentVariant = function (experimentName, variantName) { 186 | experiments[experimentName] = experiments[experimentName] || {}; 187 | experimentWeights[experimentName] = experimentWeights[experimentName] || {}; 188 | if (experiments[experimentName][variantName] !== true) { 189 | if (experimentsWithDefinedVariants[experimentName]) { 190 | var error = new Error("Experiment “" + experimentName + "” added new variants after variants were defined."); 191 | error.type = "PUSHTELL_INVALID_VARIANT"; 192 | throw error; 193 | } 194 | if (values[experimentName]) { 195 | var _error = new Error("Experiment “" + experimentName + "” added new variants after a variant was selected. Declare the variant names using emitter.defineVariants(experimentName, variantNames)."); 196 | _error.type = "PUSHTELL_INVALID_VARIANT"; 197 | throw _error; 198 | } 199 | experimentWeights[experimentName][variantName] = 1; 200 | } 201 | experiments[experimentName][variantName] = true; 202 | }; 203 | 204 | exports.default = new PushtellEventEmitter(); 205 | ; 206 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/mixpanel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _emitter = require("../emitter"); 8 | 9 | var _emitter2 = _interopRequireDefault(_emitter); 10 | 11 | var _ExecutionEnvironment = require("fbjs/lib/ExecutionEnvironment"); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | var playSubscription = void 0, 16 | winSubscription = void 0; 17 | 18 | exports.default = { 19 | enable: function enable() { 20 | if (_ExecutionEnvironment.canUseDOM) { 21 | if (typeof mixpanel === "undefined") { 22 | var error = new Error("React A/B Test Mixpanel Helper: 'mixpanel' global is not defined."); 23 | error.type = "PUSHTELL_HELPER_MISSING_GLOBAL"; 24 | throw error; 25 | } 26 | playSubscription = _emitter2.default.addPlayListener(function (experimentName, variantName) { 27 | mixpanel.track("Experiment Play", { 28 | "Experiment": experimentName, 29 | "Variant": variantName 30 | }, function () { 31 | _emitter2.default.emit("mixpanel-play", experimentName, variantName); 32 | }); 33 | }); 34 | winSubscription = _emitter2.default.addWinListener(function (experimentName, variantName) { 35 | mixpanel.track("Experiment Win", { 36 | "Experiment": experimentName, 37 | "Variant": variantName 38 | }, function () { 39 | _emitter2.default.emit("mixpanel-win", experimentName, variantName); 40 | }); 41 | }); 42 | } 43 | }, 44 | disable: function disable() { 45 | if (_ExecutionEnvironment.canUseDOM) { 46 | if (!playSubscription || !winSubscription) { 47 | var error = new Error("React A/B Test Mixpanel Helper: Helper was not enabled."); 48 | error.type = "PUSHTELL_HELPER_INVALID_DISABLE"; 49 | throw error; 50 | } 51 | playSubscription.remove(); 52 | winSubscription.remove(); 53 | } 54 | } 55 | }; 56 | module.exports = exports['default']; -------------------------------------------------------------------------------- /lib/helpers/segment.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _emitter = require("../emitter"); 8 | 9 | var _emitter2 = _interopRequireDefault(_emitter); 10 | 11 | var _ExecutionEnvironment = require("fbjs/lib/ExecutionEnvironment"); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | var playSubscription = void 0, 16 | winSubscription = void 0; 17 | 18 | exports.default = { 19 | enable: function enable() { 20 | if (_ExecutionEnvironment.canUseDOM) { 21 | if (typeof analytics === "undefined") { 22 | var error = new Error("React A/B Test Segment Helper: 'analytics' global is not defined."); 23 | error.type = "PUSHTELL_HELPER_MISSING_GLOBAL"; 24 | throw error; 25 | } 26 | playSubscription = _emitter2.default.addPlayListener(function (experimentName, variantName) { 27 | analytics.track("Experiment Viewed", { 28 | "experimentName": experimentName, 29 | "variationName": variantName 30 | }, function () { 31 | _emitter2.default.emit("segment-play", experimentName, variantName); 32 | }); 33 | }); 34 | winSubscription = _emitter2.default.addWinListener(function (experimentName, variantName) { 35 | analytics.track("Experiment Won", { 36 | "experimentName": experimentName, 37 | "variationName": variantName 38 | }, function () { 39 | _emitter2.default.emit("segment-win", experimentName, variantName); 40 | }); 41 | }); 42 | } 43 | }, 44 | disable: function disable() { 45 | if (_ExecutionEnvironment.canUseDOM) { 46 | if (!playSubscription || !winSubscription) { 47 | var error = new Error("React A/B Test Segment Helper: Helper was not enabled."); 48 | error.type = "PUSHTELL_HELPER_INVALID_DISABLE"; 49 | throw error; 50 | } 51 | playSubscription.remove(); 52 | winSubscription.remove(); 53 | } 54 | } 55 | }; 56 | module.exports = exports['default']; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ab-test", 3 | "keywords": [ 4 | "react", 5 | "react-component", 6 | "testing", 7 | "test", 8 | "A/B", 9 | "ab", 10 | "A/B testing", 11 | "A/B test" 12 | ], 13 | "main": "index.js", 14 | "version": "2.0.1", 15 | "description": "A/B testing React components and debug tools. Isomorphic with a simple, universal interface. Well documented and lightweight. Tested in popular browsers and Node.js. Includes helpers for Mixpanel and Segment.com.", 16 | "directories": { 17 | "test": "test" 18 | }, 19 | "peerDependencies": { 20 | "react": ">=0.14.0" 21 | }, 22 | "dependencies": { 23 | "fbemitter": "^2.0.2", 24 | "fbjs": "~0", 25 | "prop-types": "^15.5.8" 26 | }, 27 | "devDependencies": { 28 | "assert": "^1.3.0", 29 | "babel": "^6.5.2", 30 | "babel-cli": "^6.16.0", 31 | "babel-eslint": "^7.2.3", 32 | "babel-loader": "^6.2.4", 33 | "babel-plugin-add-module-exports": "^0.1.2", 34 | "babel-plugin-transform-class-properties": "^6.24.1", 35 | "babel-polyfill": "^6.7.4", 36 | "babel-preset-es2015": "^6.6.0", 37 | "babel-preset-react": "^6.5.0", 38 | "babel-preset-stage-1": "^6.5.0", 39 | "chai": "^4.0.2", 40 | "co": "^4.6.0", 41 | "doctoc": "^1.2.0", 42 | "envify": "^3.4.0", 43 | "es6-promise": "^3.1.2", 44 | "eslint": "^3.19.0", 45 | "eslint-plugin-babel": "^4.1.1", 46 | "eslint-plugin-promise": "^3.5.0", 47 | "eslint-plugin-react": "^6.10.3", 48 | "expose-loader": "^0.7.1", 49 | "fbemitter": "^2.0.2", 50 | "fbjs": "^0.8.0", 51 | "isparta-loader": "^2.0.0", 52 | "karma": "^0.13.22", 53 | "karma-browserstack-launcher": "^0.1.10", 54 | "karma-chrome-launcher": "^0.2.3", 55 | "karma-coverage": "^0.5.5", 56 | "karma-coveralls": "^1.1.2", 57 | "karma-firefox-launcher": "^0.1.7", 58 | "karma-mocha": "^0.2.2", 59 | "karma-sourcemap-loader": "^0.3.7", 60 | "karma-webpack": "^1.7.0", 61 | "mocha": "^2.4.5", 62 | "node-uuid": "^1.4.7", 63 | "react": "^15.0.1", 64 | "react-dom": "^15.5.4", 65 | "regenerator": "^0.8.42", 66 | "regenerator-loader": "^2.0.0", 67 | "transform-loader": "^0.2.3", 68 | "webpack": "^1.12.14" 69 | }, 70 | "scripts": { 71 | "test": "./node_modules/karma/bin/karma start test/browser/karma.conf.js; mocha --require babel-core/register --require babel-polyfill test/isomorphic/*.jsx", 72 | "build": "./node_modules/.bin/doctoc . --github --title '

Table of Contents

'; ./node_modules/babel-cli/bin/babel.js --presets es2015,stage-1,react --plugins add-module-exports ./src --out-dir ./lib; ./node_modules/webpack/bin/webpack.js --config webpack.standalone.config.js", 73 | "lint": "eslint .", 74 | "lint:fix": "eslint --fix ." 75 | }, 76 | "repository": { 77 | "type": "git", 78 | "url": "git+https://github.com/pushtell/react-ab-test.git" 79 | }, 80 | "author": "", 81 | "license": "MIT", 82 | "bugs": { 83 | "url": "https://github.com/pushtell/react-ab-test/issues" 84 | }, 85 | "homepage": "https://github.com/pushtell/react-ab-test#readme" 86 | } 87 | -------------------------------------------------------------------------------- /src/CoreExperiment.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import warning from 'fbjs/lib/warning'; 4 | import emitter from "./emitter"; 5 | import Variant from "./Variant"; 6 | 7 | export default class CoreExperiment extends Component { 8 | static propTypes = { 9 | name: PropTypes.string.isRequired, 10 | value: PropTypes.oneOfType([ 11 | PropTypes.string, 12 | PropTypes.func 13 | ]).isRequired 14 | }; 15 | 16 | win = () => { 17 | emitter.emitWin(this.props.name); 18 | }; 19 | 20 | state = {}; 21 | 22 | displayName = "Pushtell.CoreExperiment"; 23 | 24 | constructor(props) { 25 | super(); 26 | 27 | let children = {}; 28 | React.Children.forEach(props.children, element => { 29 | if (!React.isValidElement(element) || element.type.displayName !== "Pushtell.Variant") { 30 | let error = new Error("Pushtell Experiment children must be Pushtell Variant components."); 31 | error.type = "PUSHTELL_INVALID_CHILD"; 32 | throw error; 33 | } 34 | children[element.props.name] = element; 35 | emitter.addExperimentVariant(props.name, element.props.name); 36 | }); 37 | emitter.emit("variants-loaded", props.name); 38 | this.state.variants = children; 39 | } 40 | 41 | componentWillReceiveProps(nextProps) { 42 | if (nextProps.value !== this.props.value || nextProps.children !== this.props.children) { 43 | let value = typeof nextProps.value === "function" ? nextProps.value() : nextProps.value; 44 | let children = {}; 45 | React.Children.forEach(nextProps.children, element => { 46 | children[element.props.name] = element; 47 | }); 48 | this.setState({ 49 | value: value, 50 | variants: children 51 | }); 52 | } 53 | } 54 | 55 | componentWillMount() { 56 | let value = typeof this.props.value === "function" ? this.props.value() : this.props.value; 57 | if (!this.state.variants[value]) { 58 | if ("production" !== process.env.NODE_ENV) { 59 | warning(true, 'Experiment “' + this.props.name + '” does not contain variant “' + value + '”'); 60 | } 61 | } 62 | emitter._incrementActiveExperiments(this.props.name); 63 | emitter.setActiveVariant(this.props.name, value); 64 | emitter._emitPlay(this.props.name, value); 65 | this.setState({ 66 | value: value 67 | }); 68 | this.valueSubscription = emitter.addActiveVariantListener(this.props.name, (experimentName, variantName) => { 69 | this.setState({ 70 | value: variantName 71 | }); 72 | }); 73 | } 74 | 75 | componentWillUnmount() { 76 | emitter._decrementActiveExperiments(this.props.name); 77 | this.valueSubscription.remove(); 78 | } 79 | 80 | render() { 81 | return this.state.variants[this.state.value] || null; 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/Experiment.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import PropTypes from 'prop-types'; 3 | import CoreExperiment from "./CoreExperiment"; 4 | import emitter from "./emitter"; 5 | import crc32 from "fbjs/lib/crc32"; 6 | 7 | let store; 8 | 9 | const noopStore = { 10 | getItem: function(){}, 11 | setItem: function(){} 12 | }; 13 | 14 | if (typeof window !== 'undefined' && 'localStorage' in window && window['localStorage'] !== null) { 15 | try { 16 | let key = '__pushtell_react__'; 17 | window.localStorage.setItem(key, key); 18 | if (window.localStorage.getItem(key) !== key) { 19 | store = noopStore; 20 | } else { 21 | window.localStorage.removeItem(key); 22 | store = window.localStorage; 23 | } 24 | } catch (e) { 25 | store = noopStore; 26 | } 27 | } else { 28 | store = noopStore; 29 | } 30 | 31 | emitter.addActiveVariantListener(function (experimentName, variantName, skipSave) { 32 | if (skipSave) { 33 | return; 34 | } 35 | store.setItem('PUSHTELL-' + experimentName, variantName); 36 | }); 37 | 38 | export default class Experiment extends Component { 39 | static propTypes = { 40 | name: PropTypes.string.isRequired, 41 | defaultVariantName: PropTypes.string, 42 | userIdentifier: PropTypes.string 43 | }; 44 | 45 | static displayName = "Pushtell.Experiment"; 46 | 47 | win = () => { 48 | emitter.emitWin(this.props.name); 49 | }; 50 | 51 | getSelectedVariant = () => { 52 | /* 53 | 54 | Choosing a weighted variant: 55 | For C, A, B with weights 2, 4, 8 56 | 57 | variants = A, B, C 58 | weights = 4, 8, 2 59 | weightSum = 14 60 | weightedIndex = 9 61 | 62 | AAAABBBBBBBBCC 63 | ========^ 64 | Select B 65 | 66 | */ 67 | 68 | // Sorted array of the variant names, example: ["A", "B", "C"] 69 | const variants = emitter.getSortedVariants(this.props.name); 70 | // Array of the variant weights, also sorted by variant name. For example, if 71 | // variant C had weight 2, variant A had weight 4, and variant B had weight 8 72 | // return [4, 8, 2] to correspond with ["A", "B", "C"] 73 | const weights = emitter.getSortedVariantWeights(this.props.name); 74 | // Sum the weights 75 | const weightSum = weights.reduce((a, b) => { 76 | return a + b; 77 | }, 0); 78 | // A random number between 0 and weightSum 79 | let weightedIndex = typeof this.props.userIdentifier === 'string' ? Math.abs(crc32(this.props.userIdentifier) % weightSum) : Math.floor(Math.random() * weightSum); 80 | // Iterate through the sorted weights, and deduct each from the weightedIndex. 81 | // If weightedIndex drops < 0, select the variant. If weightedIndex does not 82 | // drop < 0, default to the last variant in the array that is initially assigned. 83 | let selectedVariant = variants[variants.length - 1]; 84 | for (let index = 0; index < weights.length; index++) { 85 | weightedIndex -= weights[index]; 86 | if (weightedIndex < 0) { 87 | selectedVariant = variants[index]; 88 | break; 89 | } 90 | } 91 | emitter.setActiveVariant(this.props.name, selectedVariant); 92 | return selectedVariant; 93 | } 94 | 95 | getLocalStorageValue = () => { 96 | if(typeof this.props.userIdentifier === "string") { 97 | return this.getSelectedVariant(); 98 | } 99 | const activeValue = emitter.getActiveVariant(this.props.name); 100 | if(typeof activeValue === "string") { 101 | return activeValue; 102 | } 103 | const storedValue = store.getItem('PUSHTELL-' + this.props.name); 104 | if(typeof storedValue === "string") { 105 | emitter.setActiveVariant(this.props.name, storedValue, true); 106 | return storedValue; 107 | } 108 | if(typeof this.props.defaultVariantName === 'string') { 109 | emitter.setActiveVariant(this.props.name, this.props.defaultVariantName); 110 | return this.props.defaultVariantName; 111 | } 112 | return this.getSelectedVariant(); 113 | } 114 | 115 | render() { 116 | return ; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Variant.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Variant extends Component { 5 | static propTypes = { 6 | name: PropTypes.string.isRequired 7 | }; 8 | 9 | static displayName = "Pushtell.Variant"; 10 | 11 | render() { 12 | if (React.isValidElement(this.props.children)) { 13 | return this.props.children; 14 | } else { 15 | return {this.props.children}; 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/debugger.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import emitter from "./emitter"; 4 | import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment'; 5 | 6 | if(process.env.NODE_ENV === "production" || !canUseDOM) { 7 | module.exports = { 8 | enable() {}, 9 | disable() {} 10 | } 11 | } else { 12 | let style = null; 13 | function attachStyleSheet() { 14 | style = document.createElement("style"); 15 | style.appendChild(document.createTextNode("")); 16 | document.head.appendChild(style); 17 | function addCSSRule(selector, rules) { 18 | if("insertRule" in style.sheet) { 19 | style.sheet.insertRule(selector + "{" + rules + "}", 0); 20 | } else if("addRule" in style.sheet) { 21 | style.sheet.addRule(selector, rules, 0); 22 | } 23 | } 24 | addCSSRule("#pushtell-debugger", "z-index: 25000"); 25 | addCSSRule("#pushtell-debugger", "position: fixed"); 26 | addCSSRule("#pushtell-debugger", "transform: translateX(-50%)"); 27 | addCSSRule("#pushtell-debugger", "bottom: 0"); 28 | addCSSRule("#pushtell-debugger", "left: 50%"); 29 | addCSSRule("#pushtell-debugger ul", "margin: 0"); 30 | addCSSRule("#pushtell-debugger ul", "padding: 0 0 0 20px"); 31 | addCSSRule("#pushtell-debugger li", "margin: 0"); 32 | addCSSRule("#pushtell-debugger li", "padding: 0"); 33 | addCSSRule("#pushtell-debugger li", "font-size: 14px"); 34 | addCSSRule("#pushtell-debugger li", "line-height: 14px"); 35 | addCSSRule("#pushtell-debugger input", "float: left"); 36 | addCSSRule("#pushtell-debugger input", "margin: 0 10px 0 0"); 37 | addCSSRule("#pushtell-debugger input", "padding: 0"); 38 | addCSSRule("#pushtell-debugger input", "cursor: pointer"); 39 | addCSSRule("#pushtell-debugger label", "color: #999999"); 40 | addCSSRule("#pushtell-debugger label", "margin: 0 0 10px 0"); 41 | addCSSRule("#pushtell-debugger label", "cursor: pointer"); 42 | addCSSRule("#pushtell-debugger label", "font-weight: normal"); 43 | addCSSRule("#pushtell-debugger label.active", "color: #000000"); 44 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "font-size: 16px"); 45 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "color: #000000"); 46 | addCSSRule("#pushtell-debugger .pushtell-experiment-name", "margin: 0 0 10px 0"); 47 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "font-size: 10px"); 48 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "color: #999999"); 49 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "text-align: center"); 50 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "margin: 10px -40px 0 -10px"); 51 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "border-top: 1px solid #b3b3b3"); 52 | addCSSRule("#pushtell-debugger .pushtell-production-build-note", "padding: 10px 10px 5px 10px"); 53 | addCSSRule("#pushtell-debugger .pushtell-handle", "cursor: pointer"); 54 | addCSSRule("#pushtell-debugger .pushtell-handle", "padding: 5px 10px 5px 10px"); 55 | addCSSRule("#pushtell-debugger .pushtell-panel", "padding: 15px 40px 5px 10px"); 56 | addCSSRule("#pushtell-debugger .pushtell-container", "font-size: 11px"); 57 | addCSSRule("#pushtell-debugger .pushtell-container", "background-color: #ebebeb"); 58 | addCSSRule("#pushtell-debugger .pushtell-container", "color: #000000"); 59 | addCSSRule("#pushtell-debugger .pushtell-container", "box-shadow: 0px 0 5px rgba(0, 0, 0, 0.1)"); 60 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top: 1px solid #b3b3b3"); 61 | addCSSRule("#pushtell-debugger .pushtell-container", "border-left: 1px solid #b3b3b3"); 62 | addCSSRule("#pushtell-debugger .pushtell-container", "border-right: 1px solid #b3b3b3"); 63 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top-left-radius: 2px"); 64 | addCSSRule("#pushtell-debugger .pushtell-container", "border-top-right-radius: 2px"); 65 | addCSSRule("#pushtell-debugger .pushtell-close", "cursor: pointer"); 66 | addCSSRule("#pushtell-debugger .pushtell-close", "font-size: 16px"); 67 | addCSSRule("#pushtell-debugger .pushtell-close", "font-weight: bold"); 68 | addCSSRule("#pushtell-debugger .pushtell-close", "color: #CC0000"); 69 | addCSSRule("#pushtell-debugger .pushtell-close", "position: absolute"); 70 | addCSSRule("#pushtell-debugger .pushtell-close", "top: 0px"); 71 | addCSSRule("#pushtell-debugger .pushtell-close", "right: 7px"); 72 | addCSSRule("#pushtell-debugger .pushtell-close:hover", "color: #FF0000"); 73 | addCSSRule("#pushtell-debugger .pushtell-close, #pushtell-debugger label", "transition: all .25s"); 74 | } 75 | function removeStyleSheet() { 76 | if(style !== null){ 77 | document.head.removeChild(style); 78 | style = null; 79 | } 80 | } 81 | const Debugger = React.createClass({ 82 | displayName: "Pushtell.Debugger", 83 | getInitialState(){ 84 | return { 85 | experiments: emitter.getActiveExperiments(), 86 | visible: false 87 | }; 88 | }, 89 | toggleVisibility() { 90 | this.setState({ 91 | visible: !this.state.visible 92 | }); 93 | }, 94 | updateExperiments(){ 95 | this.setState({ 96 | experiments: emitter.getActiveExperiments() 97 | }); 98 | }, 99 | setActiveVariant(experimentName, variantName) { 100 | emitter.setActiveVariant(experimentName, variantName); 101 | }, 102 | componentWillMount(){ 103 | this.activeSubscription = emitter.addListener("active", this.updateExperiments); 104 | this.inactiveSubscription = emitter.addListener("inactive", this.updateExperiments); 105 | }, 106 | componentWillUnmount(){ 107 | this.activeSubscription.remove(); 108 | this.inactiveSubscription.remove(); 109 | }, 110 | render(){ 111 | var experimentNames = Object.keys(this.state.experiments); 112 | if(this.state.visible) { 113 | return
114 |
×
115 | {experimentNames.map(experimentName => { 116 | var variantNames = Object.keys(this.state.experiments[experimentName]); 117 | if(variantNames.length === 0) { 118 | return; 119 | } 120 | return
121 |
{experimentName}
122 |
    123 | {variantNames.map(variantName => { 124 | return
  • 125 | 129 |
  • 130 | })} 131 |
132 |
; 133 | })} 134 |
This panel is hidden on production builds.
135 |
; 136 | } else if(experimentNames.length > 0){ 137 | return
138 | {experimentNames.length} Active Experiment{experimentNames.length > 1 ? "s" : ""} 139 |
; 140 | } else { 141 | return null; 142 | } 143 | } 144 | }); 145 | 146 | module.exports = { 147 | enable() { 148 | attachStyleSheet(); 149 | let body = document.getElementsByTagName('body')[0]; 150 | let container = document.createElement('div'); 151 | container.id = 'pushtell-debugger'; 152 | body.appendChild(container); 153 | ReactDOM.render(, container); 154 | }, 155 | disable() { 156 | removeStyleSheet(); 157 | let body = document.getElementsByTagName('body')[0]; 158 | let container = document.getElementById('pushtell-debugger'); 159 | if(container) { 160 | ReactDOM.unmountComponentAtNode(container); 161 | body.removeChild(container); 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/emitter.jsx: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'fbemitter'; 2 | 3 | let values = {}; 4 | let experiments = {}; 5 | let experimentWeights = {}; 6 | let activeExperiments = {}; 7 | let experimentsWithDefinedVariants = {}; 8 | let playedExperiments = {}; 9 | 10 | const emitter = new EventEmitter(); 11 | 12 | const PushtellEventEmitter = function() {}; 13 | 14 | PushtellEventEmitter.prototype.emitWin = function(experimentName){ 15 | if(typeof experimentName !== 'string') { 16 | throw new Error("Required argument 'experimentName' should have type 'string'"); 17 | } 18 | emitter.emit("win", experimentName, values[experimentName]); 19 | }; 20 | 21 | PushtellEventEmitter.prototype._emitPlay = function(experimentName, variantName){ 22 | if(typeof experimentName !== 'string') { 23 | throw new Error("Required argument 'experimentName' should have type 'string'"); 24 | } 25 | if(typeof variantName !== 'string') { 26 | throw new Error("Required argument 'variantName' should have type 'string'"); 27 | } 28 | if(!playedExperiments[experimentName]) { 29 | emitter.emit('play', experimentName, variantName); 30 | playedExperiments[experimentName] = true; 31 | } 32 | }; 33 | 34 | PushtellEventEmitter.prototype._resetPlayedExperiments = function(){ 35 | values = {}; 36 | playedExperiments = {}; 37 | } 38 | 39 | PushtellEventEmitter.prototype._reset = function(){ 40 | values = {}; 41 | experiments = {}; 42 | experimentWeights = {}; 43 | activeExperiments = {}; 44 | experimentsWithDefinedVariants = {}; 45 | playedExperiments = {}; 46 | } 47 | 48 | PushtellEventEmitter.prototype.rewind = function() { 49 | this._reset(); 50 | emitter.removeAllListeners(); 51 | } 52 | 53 | PushtellEventEmitter.prototype._incrementActiveExperiments = function(experimentName) { 54 | activeExperiments[experimentName] = activeExperiments[experimentName] || 0; 55 | activeExperiments[experimentName] += 1; 56 | emitter.emit("active", experimentName); 57 | } 58 | 59 | PushtellEventEmitter.prototype._decrementActiveExperiments = function(experimentName) { 60 | activeExperiments[experimentName] -= 1; 61 | emitter.emit("inactive", experimentName); 62 | } 63 | 64 | PushtellEventEmitter.prototype.addActiveVariantListener = function(experimentName, callback) { 65 | if(typeof experimentName === "function") { 66 | callback = experimentName; 67 | return emitter.addListener("active-variant", (_experimentName, variantName, passthrough) => { 68 | callback(_experimentName, variantName, passthrough); 69 | }); 70 | } 71 | return emitter.addListener("active-variant", (_experimentName, variantName, passthrough) => { 72 | if(_experimentName === experimentName) { 73 | callback(_experimentName, variantName, passthrough); 74 | } 75 | }); 76 | }; 77 | 78 | PushtellEventEmitter.prototype.emit = function() { 79 | return emitter.emit.apply(emitter, arguments); 80 | }; 81 | 82 | PushtellEventEmitter.prototype.addListener = function(eventName, callback) { 83 | return emitter.addListener(eventName, callback); 84 | }; 85 | 86 | PushtellEventEmitter.prototype.once = function(eventName, callback) { 87 | return emitter.once(eventName, callback); 88 | }; 89 | 90 | PushtellEventEmitter.prototype.addPlayListener = function(experimentName, callback) { 91 | if(typeof experimentName === "function") { 92 | callback = experimentName; 93 | return emitter.addListener('play', (_experimentName, variantName) => { 94 | callback(_experimentName, variantName); 95 | }); 96 | } 97 | return emitter.addListener('play', (_experimentName, variantName) => { 98 | if(_experimentName === experimentName) { 99 | callback(_experimentName, variantName); 100 | } 101 | }); 102 | }; 103 | 104 | PushtellEventEmitter.prototype.addWinListener = function(experimentName, callback) { 105 | if(typeof experimentName === "function") { 106 | callback = experimentName; 107 | return emitter.addListener('win', (_experimentName, variantName) => { 108 | callback(_experimentName, variantName); 109 | }); 110 | } 111 | return emitter.addListener('win', (_experimentName, variantName) => { 112 | if(_experimentName === experimentName) { 113 | callback(_experimentName, variantName); 114 | } 115 | }); 116 | }; 117 | 118 | PushtellEventEmitter.prototype.defineVariants = function(experimentName, variantNames, variantWeights){ 119 | const variantsNamesMap = {}; 120 | const variantWeightsMap = {}; 121 | variantNames.forEach(variantName => { 122 | variantsNamesMap[variantName] = true; 123 | }); 124 | if(Array.isArray(variantWeights)) { 125 | if(variantNames.length !== variantWeights.length) { 126 | throw new Error("Required argument 'variantNames' should have the same number of elements as optional argument 'variantWeights'"); 127 | } 128 | variantNames.forEach((variantName, index) => { 129 | if(typeof variantWeights[index] !== 'number') { 130 | throw new Error("Optional argument 'variantWeights' should be an array of numbers."); 131 | } 132 | variantWeightsMap[variantName] = variantWeights[index]; 133 | }); 134 | } else { 135 | variantNames.forEach((variantName, index) => { 136 | variantWeightsMap[variantName] = 1; 137 | }); 138 | } 139 | experimentWeights[experimentName] = variantWeightsMap; 140 | experiments[experimentName] = variantsNamesMap; 141 | experimentsWithDefinedVariants[experimentName] = true; 142 | }; 143 | 144 | PushtellEventEmitter.prototype.getSortedVariants = function(experimentName) { 145 | const variantNames = Object.keys(experiments[experimentName]); 146 | variantNames.sort(); 147 | return variantNames; 148 | }; 149 | 150 | PushtellEventEmitter.prototype.getSortedVariantWeights = function(experimentName) { 151 | return this.getSortedVariants(experimentName).map(function(variantName){ 152 | return experimentWeights[experimentName][variantName]; 153 | }); 154 | }; 155 | 156 | PushtellEventEmitter.prototype.getActiveExperiments = function(){ 157 | const response = {}; 158 | Object.keys(activeExperiments).forEach(experimentName => { 159 | if(activeExperiments[experimentName] === 0) { 160 | return; 161 | } 162 | response[experimentName] = {}; 163 | Object.keys(experiments[experimentName]).forEach(variantName => { 164 | response[experimentName][variantName] = values[experimentName] === variantName; 165 | }); 166 | }); 167 | return response; 168 | } 169 | 170 | PushtellEventEmitter.prototype.getActiveVariant = function(experimentName){ 171 | return values[experimentName]; 172 | } 173 | 174 | PushtellEventEmitter.prototype.setActiveVariant = function(experimentName, variantName, passthrough){ 175 | values[experimentName] = variantName; 176 | emitter.emit("active-variant", experimentName, variantName, passthrough); 177 | } 178 | 179 | PushtellEventEmitter.prototype.addExperimentVariant = function(experimentName, variantName){ 180 | experiments[experimentName] = experiments[experimentName] || {}; 181 | experimentWeights[experimentName] = experimentWeights[experimentName] || {}; 182 | if(experiments[experimentName][variantName] !== true) { 183 | if(experimentsWithDefinedVariants[experimentName]) { 184 | const error = new Error("Experiment “" + experimentName + "” added new variants after variants were defined."); 185 | error.type = "PUSHTELL_INVALID_VARIANT"; 186 | throw error; 187 | } 188 | if(values[experimentName]) { 189 | const error = new Error("Experiment “" + experimentName + "” added new variants after a variant was selected. Declare the variant names using emitter.defineVariants(experimentName, variantNames)."); 190 | error.type = "PUSHTELL_INVALID_VARIANT"; 191 | throw error; 192 | } 193 | experimentWeights[experimentName][variantName] = 1; 194 | } 195 | experiments[experimentName][variantName] = true; 196 | } 197 | 198 | export default new PushtellEventEmitter();; -------------------------------------------------------------------------------- /src/helpers/mixpanel.jsx: -------------------------------------------------------------------------------- 1 | import emitter from "../emitter"; 2 | import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment'; 3 | 4 | let playSubscription, winSubscription; 5 | 6 | export default { 7 | enable(){ 8 | if(canUseDOM) { 9 | if(typeof mixpanel === "undefined") { 10 | const error = new Error("React A/B Test Mixpanel Helper: 'mixpanel' global is not defined."); 11 | error.type = "PUSHTELL_HELPER_MISSING_GLOBAL"; 12 | throw error; 13 | } 14 | playSubscription = emitter.addPlayListener(function(experimentName, variantName){ 15 | mixpanel.track("Experiment Play", { 16 | "Experiment": experimentName, 17 | "Variant": variantName 18 | }, function(){ 19 | emitter.emit("mixpanel-play", experimentName, variantName); 20 | }); 21 | }); 22 | winSubscription = emitter.addWinListener(function(experimentName, variantName){ 23 | mixpanel.track("Experiment Win", { 24 | "Experiment": experimentName, 25 | "Variant": variantName 26 | }, function(){ 27 | emitter.emit("mixpanel-win", experimentName, variantName); 28 | }); 29 | }); 30 | } 31 | }, 32 | disable(){ 33 | if(canUseDOM) { 34 | if(!playSubscription || !winSubscription) { 35 | const error = new Error("React A/B Test Mixpanel Helper: Helper was not enabled."); 36 | error.type = "PUSHTELL_HELPER_INVALID_DISABLE"; 37 | throw error; 38 | } 39 | playSubscription.remove(); 40 | winSubscription.remove(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/helpers/segment.jsx: -------------------------------------------------------------------------------- 1 | import emitter from "../emitter"; 2 | import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment'; 3 | 4 | let playSubscription, winSubscription; 5 | 6 | export default { 7 | enable(){ 8 | if(canUseDOM) { 9 | if(typeof analytics === "undefined") { 10 | const error = new Error("React A/B Test Segment Helper: 'analytics' global is not defined."); 11 | error.type = "PUSHTELL_HELPER_MISSING_GLOBAL"; 12 | throw error; 13 | } 14 | playSubscription = emitter.addPlayListener(function(experimentName, variantName){ 15 | analytics.track("Experiment Viewed", { 16 | "experimentName": experimentName, 17 | "variationName": variantName 18 | }, function(){ 19 | emitter.emit("segment-play", experimentName, variantName); 20 | }); 21 | }); 22 | winSubscription = emitter.addWinListener(function(experimentName, variantName){ 23 | analytics.track("Experiment Won", { 24 | "experimentName": experimentName, 25 | "variationName": variantName 26 | }, function(){ 27 | emitter.emit("segment-win", experimentName, variantName); 28 | }); 29 | }); 30 | } 31 | }, 32 | disable(){ 33 | if(canUseDOM) { 34 | if(!playSubscription || !winSubscription) { 35 | const error = new Error("React A/B Test Segment Helper: Helper was not enabled."); 36 | error.type = "PUSHTELL_HELPER_INVALID_DISABLE"; 37 | throw error; 38 | } 39 | playSubscription.remove(); 40 | winSubscription.remove(); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/browser/core.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import co from "co"; 8 | import UUID from "node-uuid"; 9 | import ES6Promise from 'es6-promise'; 10 | ES6Promise.polyfill(); 11 | 12 | describe("Core Experiment", function() { 13 | let container; 14 | before(function(){ 15 | container = document.createElement("div"); 16 | container.id = "react"; 17 | document.getElementsByTagName('body')[0].appendChild(container); 18 | }); 19 | after(function(){ 20 | document.getElementsByTagName('body')[0].removeChild(container); 21 | emitter._reset(); 22 | }); 23 | it("should render the correct variant.", co.wrap(function *(){ 24 | let experimentName = UUID.v4(); 25 | let App = React.createClass({ 26 | render: function(){ 27 | return 28 |
29 |
30 | ; 31 | } 32 | }); 33 | yield new Promise(function(resolve, reject){ 34 | ReactDOM.render(, container, resolve); 35 | }); 36 | let elementA = document.getElementById('variant-a'); 37 | let elementB = document.getElementById('variant-b'); 38 | assert.notEqual(elementA, null); 39 | assert.equal(elementB, null); 40 | ReactDOM.unmountComponentAtNode(container); 41 | })); 42 | it("should error if invalid children exist.", co.wrap(function *(){ 43 | let experimentName = UUID.v4(); 44 | let App = React.createClass({ 45 | render: function(){ 46 | return 47 |
48 |
49 | ; 50 | } 51 | }); 52 | try { 53 | yield new Promise(function(resolve, reject){ 54 | ReactDOM.render(, container, resolve); 55 | }); 56 | } catch(error) { 57 | if(error.type !== "PUSHTELL_INVALID_CHILD") { 58 | throw error; 59 | } 60 | ReactDOM.unmountComponentAtNode(container); 61 | return; 62 | } 63 | throw new Error("Experiment has invalid children."); 64 | })); 65 | it("should update on componentWillReceiveProps.", co.wrap(function *(){ 66 | let experimentName = UUID.v4(); 67 | let setState; 68 | let getValueA = function(){ 69 | return "A"; 70 | } 71 | let getValueB = function() { 72 | return "B"; 73 | } 74 | let App = React.createClass({ 75 | getInitialState: function(){ 76 | return { 77 | value: getValueA 78 | } 79 | }, 80 | componentWillMount: function(){ 81 | setState = this.setState.bind(this); 82 | }, 83 | render: function(){ 84 | return 85 |
86 |
87 | ; 88 | } 89 | }); 90 | yield new Promise(function(resolve, reject){ 91 | ReactDOM.render(, container, resolve); 92 | }); 93 | let elementA = document.getElementById('variant-a'); 94 | let elementB = document.getElementById('variant-b'); 95 | assert.notEqual(elementA, null); 96 | assert.equal(elementB, null); 97 | setState({ 98 | value: getValueB 99 | }); 100 | elementA = document.getElementById('variant-a'); 101 | elementB = document.getElementById('variant-b'); 102 | assert.equal(elementA, null); 103 | assert.notEqual(elementB, null); 104 | ReactDOM.unmountComponentAtNode(container); 105 | })); 106 | it("should update the children when props change.", co.wrap(function *(){ 107 | let experimentName = UUID.v4(); 108 | let SubComponent = React.createClass({ 109 | render(){ 110 | return ( 111 |
112 | {this.props.text} 113 |
114 | ) 115 | } 116 | }); 117 | let App = React.createClass({ 118 | render: function(){ 119 | return 120 | 121 |
122 | ; 123 | } 124 | }); 125 | yield new Promise(function(resolve, reject){ 126 | let component = ReactDOM.render(,container, resolve); 127 | }); 128 | let elementAText = document.getElementById('variant-a-text'); 129 | assert.equal(elementAText.textContent, "original text"); 130 | yield new Promise(function(resolve, reject){ 131 | component = ReactDOM.render(,container, resolve); 132 | }); 133 | elementAText = document.getElementById('variant-a-text'); 134 | assert.equal(elementAText.textContent, "New text"); 135 | ReactDOM.unmountComponentAtNode(container); 136 | })); 137 | }); 138 | -------------------------------------------------------------------------------- /test/browser/debugger.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import experimentDebugger from "../../src/debugger.jsx"; 6 | import emitter from "../../src/emitter.jsx"; 7 | import assert from "assert"; 8 | import co from "co"; 9 | import UUID from "node-uuid"; 10 | import TestUtils from 'react-dom/test-utils'; 11 | import ES6Promise from 'es6-promise'; 12 | ES6Promise.polyfill(); 13 | 14 | // See http://stackoverflow.com/a/985070 15 | 16 | function hasCSSSelector(s){ 17 | if(!document.styleSheets) { 18 | return ''; 19 | } 20 | s = s.toLowerCase(); 21 | var A, temp, n = document.styleSheets.length, SA = []; 22 | for(let i = 0; i < document.styleSheets.length; i++) { 23 | let sheet = document.styleSheets[i]; 24 | let rules = sheet.rules ? sheet.rules : sheet.cssRules; 25 | for(let j = 0; j < rules.length; j++){ 26 | let selector = rules[j].selectorText ? rules[j].selectorText : rules[j].toString(); 27 | if(selector.toLowerCase() === s) { 28 | return true; 29 | } 30 | } 31 | } 32 | return false; 33 | } 34 | 35 | describe("Debugger", function() { 36 | let container; 37 | before(function(){ 38 | container = document.createElement("div"); 39 | container.id = "react"; 40 | document.getElementsByTagName('body')[0].appendChild(container); 41 | }); 42 | after(function(){ 43 | document.getElementsByTagName('body')[0].removeChild(container); 44 | emitter._reset(); 45 | }); 46 | it("should enable and disable.", co.wrap(function *(){ 47 | let experimentName = UUID.v4(); 48 | let App = React.createClass({ 49 | render: function(){ 50 | return 51 |
52 |
53 | ; 54 | } 55 | }); 56 | yield new Promise(function(resolve, reject){ 57 | ReactDOM.render(, container, resolve); 58 | }); 59 | experimentDebugger.enable(); 60 | let element = document.getElementById('pushtell-debugger'); 61 | assert.notEqual(element, null); 62 | experimentDebugger.disable(); 63 | element = document.getElementById('pushtell-debugger'); 64 | assert.equal(element, null); 65 | ReactDOM.unmountComponentAtNode(container); 66 | })); 67 | it("should add and remove style rules", function() { 68 | experimentDebugger.enable(); 69 | assert.equal(hasCSSSelector("#pushtell-debugger"), true); 70 | experimentDebugger.disable(); 71 | assert.equal(hasCSSSelector("#pushtell-debugger"), false); 72 | }); 73 | it("should change an experiment's value.", co.wrap(function *(){ 74 | let experimentName = UUID.v4(); 75 | let App = React.createClass({ 76 | render: function(){ 77 | return 78 |
79 |
80 | ; 81 | } 82 | }); 83 | yield new Promise(function(resolve, reject){ 84 | ReactDOM.render(, container, resolve); 85 | }); 86 | experimentDebugger.enable(); 87 | let elementA = document.getElementById('variant-a'); 88 | let elementB = document.getElementById('variant-b'); 89 | assert.notEqual(elementA, null); 90 | assert.equal(elementB, null); 91 | let handle = document.querySelector("#pushtell-debugger div.pushtell-handle"); 92 | TestUtils.Simulate.click(handle); 93 | let radio_button_a = document.querySelector("#pushtell-debugger input[value='A']"); 94 | let radio_button_b = document.querySelector("#pushtell-debugger input[value='B']"); 95 | assert.equal(radio_button_a.checked, true); 96 | TestUtils.Simulate.click(radio_button_b); 97 | elementA = document.getElementById('variant-a'); 98 | elementB = document.getElementById('variant-b'); 99 | assert.equal(elementA, null); 100 | assert.notEqual(elementB, null); 101 | experimentDebugger.disable(); 102 | ReactDOM.unmountComponentAtNode(container); 103 | })); 104 | }); 105 | 106 | -------------------------------------------------------------------------------- /test/browser/emitter.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import co from "co"; 8 | import UUID from "node-uuid"; 9 | import TestUtils from 'react-dom/test-utils'; 10 | import ES6Promise from 'es6-promise'; 11 | ES6Promise.polyfill(); 12 | 13 | describe("Emitter", function() { 14 | let container; 15 | before(function(){ 16 | container = document.createElement("div"); 17 | container.id = "react"; 18 | document.getElementsByTagName('body')[0].appendChild(container); 19 | }); 20 | after(function(){ 21 | document.getElementsByTagName('body')[0].removeChild(container); 22 | emitter._reset(); 23 | }); 24 | it("should throw an error when passed an invalid name argument.", function (){ 25 | assert.throws(function(){emitter.emitWin(1)}, /type \'string\'/); 26 | }); 27 | it("should emit when a variant is played.", co.wrap(function *(){ 28 | let experimentName = UUID.v4(); 29 | let playedVariantName = null; 30 | let playCallback = function(experimentName, variantName){ 31 | playedVariantName = variantName; 32 | }; 33 | let experimentNameGlobal = null; 34 | let playedVariantNameGlobal = null; 35 | let playCallbackGlobal = function(experimentName, variantName){ 36 | experimentNameGlobal = experimentName; 37 | playedVariantNameGlobal = variantName; 38 | }; 39 | let playSubscription = emitter.addPlayListener(experimentName, playCallback); 40 | let playSubscriptionGlobal = emitter.addPlayListener(playCallbackGlobal); 41 | let App = React.createClass({ 42 | render: function(){ 43 | return 44 |
45 |
46 | ; 47 | } 48 | }); 49 | yield new Promise(function(resolve, reject){ 50 | ReactDOM.render(, container, resolve); 51 | }); 52 | assert.equal(playedVariantName, "A"); 53 | assert.equal(experimentNameGlobal, experimentName); 54 | assert.equal(playedVariantNameGlobal, "A"); 55 | playSubscription.remove(); 56 | playSubscriptionGlobal.remove(); 57 | ReactDOM.unmountComponentAtNode(container); 58 | })); 59 | it("should emit when a variant wins.", co.wrap(function *(){ 60 | let experimentName = UUID.v4(); 61 | let winningVariantName = null; 62 | let winCallback = function(experimentName, variantName){ 63 | winningVariantName = variantName; 64 | }; 65 | let experimentNameGlobal = null; 66 | let winningVariantNameGlobal = null; 67 | let winCallbackGlobal = function(experimentName, variantName){ 68 | experimentNameGlobal = experimentName; 69 | winningVariantNameGlobal = variantName; 70 | }; 71 | let winSubscription = emitter.addWinListener(experimentName, winCallback); 72 | let winSubscriptionGlobal = emitter.addWinListener(winCallbackGlobal); 73 | let App = React.createClass({ 74 | render: function(){ 75 | return 76 |
77 |
78 | ; 79 | } 80 | }); 81 | yield new Promise(function(resolve, reject){ 82 | ReactDOM.render(, container, resolve); 83 | }); 84 | emitter.emitWin(experimentName); 85 | assert.equal(winningVariantName, "A"); 86 | assert.equal(experimentNameGlobal, experimentName); 87 | assert.equal(winningVariantNameGlobal, "A"); 88 | winSubscription.remove(); 89 | winSubscriptionGlobal.remove(); 90 | ReactDOM.unmountComponentAtNode(container); 91 | })); 92 | it("should emit when a variant is clicked.", co.wrap(function *(){ 93 | let experimentName = UUID.v4(); 94 | let winningVariantName = null; 95 | let winCallback = function(experimentName, variantName){ 96 | winningVariantName = variantName; 97 | }; 98 | let experimentNameGlobal = null; 99 | let winningVariantNameGlobal = null; 100 | let winCallbackGlobal = function(experimentName, variantName){ 101 | experimentNameGlobal = experimentName; 102 | winningVariantNameGlobal = variantName; 103 | }; 104 | let winSubscription = emitter.addWinListener(experimentName, winCallback); 105 | let winSubscriptionGlobal = emitter.addWinListener(winCallbackGlobal); 106 | let App = React.createClass({ 107 | onClickVariant: function(e){ 108 | this.refs.experiment.win(); 109 | }, 110 | render: function(){ 111 | return 112 | A 113 | B 114 | ; 115 | } 116 | }); 117 | yield new Promise(function(resolve, reject){ 118 | ReactDOM.render(, container, resolve); 119 | }); 120 | let elementA = document.getElementById('variant-a'); 121 | TestUtils.Simulate.click(elementA); 122 | assert.equal(winningVariantName, "A"); 123 | assert.equal(experimentNameGlobal, experimentName); 124 | assert.equal(winningVariantNameGlobal, "A"); 125 | winSubscription.remove(); 126 | winSubscriptionGlobal.remove(); 127 | ReactDOM.unmountComponentAtNode(container); 128 | })); 129 | it("should emit when a variant is chosen.", co.wrap(function *(){ 130 | let experimentName = UUID.v4(); 131 | let activeVariantName = null; 132 | let activeVariantCallback = function(experimentName, variantName){ 133 | activeVariantName = variantName; 134 | }; 135 | let experimentNameGlobal = null; 136 | let activeVariantNameGlobal = null; 137 | let activeVariantCallbackGlobal = function(experimentName, variantName){ 138 | experimentNameGlobal = experimentName; 139 | activeVariantNameGlobal = variantName; 140 | }; 141 | let activeVariantSubscription = emitter.addActiveVariantListener(experimentName, activeVariantCallback); 142 | let activeVariantSubscriptionGlobal = emitter.addActiveVariantListener(activeVariantCallbackGlobal); 143 | let App = React.createClass({ 144 | render: function(){ 145 | return 146 | A 147 | B 148 | ; 149 | } 150 | }); 151 | yield new Promise(function(resolve, reject){ 152 | ReactDOM.render(, container, resolve); 153 | }); 154 | assert.equal(activeVariantName, "A"); 155 | assert.equal(experimentNameGlobal, experimentName); 156 | assert.equal(activeVariantNameGlobal, "A"); 157 | activeVariantSubscription.remove(); 158 | activeVariantSubscriptionGlobal.remove(); 159 | ReactDOM.unmountComponentAtNode(container); 160 | })); 161 | it("should get the experiment value.", co.wrap(function *(){ 162 | let experimentName = UUID.v4(); 163 | let App = React.createClass({ 164 | render: function(){ 165 | return 166 | A 167 | B 168 | ; 169 | } 170 | }); 171 | yield new Promise(function(resolve, reject){ 172 | ReactDOM.render(, container, resolve); 173 | }); 174 | assert.equal(emitter.getActiveVariant(experimentName), "A"); 175 | ReactDOM.unmountComponentAtNode(container); 176 | })); 177 | it("should update the rendered component.", co.wrap(function *(){ 178 | let experimentName = UUID.v4(); 179 | let App = React.createClass({ 180 | render: function(){ 181 | return 182 |
183 |
184 | ; 185 | } 186 | }); 187 | yield new Promise(function(resolve, reject){ 188 | ReactDOM.render(, container, resolve); 189 | }); 190 | let elementA = document.getElementById('variant-a'); 191 | let elementB = document.getElementById('variant-b'); 192 | assert.notEqual(elementA, null); 193 | assert.equal(elementB, null); 194 | emitter.setActiveVariant(experimentName, "B"); 195 | elementA = document.getElementById('variant-a'); 196 | elementB = document.getElementById('variant-b'); 197 | assert.equal(elementA, null); 198 | assert.notEqual(elementB, null); 199 | ReactDOM.unmountComponentAtNode(container); 200 | })); 201 | it("should report active components.", co.wrap(function *(){ 202 | let experimentNameA = UUID.v4(); 203 | let experimentNameB = UUID.v4(); 204 | let AppA = React.createClass({ 205 | render: function(){ 206 | return 207 |
208 |
209 | ; 210 | } 211 | }); 212 | let AppB = React.createClass({ 213 | render: function(){ 214 | return 215 |
216 |
217 | ; 218 | } 219 | }); 220 | let AppCombined = React.createClass({ 221 | render: function(){ 222 | return
223 | 224 | 225 |
; 226 | } 227 | }); 228 | yield new Promise(function(resolve, reject){ 229 | ReactDOM.render(, container, resolve); 230 | }); 231 | let activeExperiments = {}; 232 | activeExperiments[experimentNameA] = { 233 | "A": true, 234 | "B": false 235 | }; 236 | assert.deepEqual(emitter.getActiveExperiments(), activeExperiments); 237 | ReactDOM.unmountComponentAtNode(container); 238 | yield new Promise(function(resolve, reject){ 239 | ReactDOM.render(, container, resolve); 240 | }); 241 | activeExperiments = {}; 242 | activeExperiments[experimentNameB] = { 243 | "C": true, 244 | "D": false 245 | }; 246 | assert.deepEqual(emitter.getActiveExperiments(), activeExperiments); 247 | ReactDOM.unmountComponentAtNode(container); 248 | yield new Promise(function(resolve, reject){ 249 | ReactDOM.render(, container, resolve); 250 | }); 251 | activeExperiments = {}; 252 | activeExperiments[experimentNameA] = { 253 | "A": true, 254 | "B": false 255 | }; 256 | activeExperiments[experimentNameB] = { 257 | "C": true, 258 | "D": false 259 | }; 260 | assert.deepEqual(emitter.getActiveExperiments(), activeExperiments); 261 | ReactDOM.unmountComponentAtNode(container); 262 | })); 263 | }); 264 | 265 | -------------------------------------------------------------------------------- /test/browser/experiment.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/Experiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import co from "co"; 8 | import UUID from "node-uuid"; 9 | import TestUtils from 'react-dom/test-utils'; 10 | import ES6Promise from 'es6-promise'; 11 | ES6Promise.polyfill(); 12 | 13 | let store; 14 | 15 | let noopStore = { 16 | getItem: function(){}, 17 | setItem: function(){}, 18 | clear: function(){} 19 | }; 20 | 21 | if(typeof window !== 'undefined' && 'localStorage' in window && window['localStorage'] !== null) { 22 | try { 23 | let key = '__pushtell_react__'; 24 | window.localStorage.setItem(key, key); 25 | if (window.localStorage.getItem(key) !== key) { 26 | store = noopStore; 27 | } else { 28 | window.localStorage.removeItem(key); 29 | store = window.localStorage; 30 | } 31 | } catch(e) { 32 | store = noopStore; 33 | } 34 | } else { 35 | store = noopStore; 36 | } 37 | 38 | describe("Experiment", function() { 39 | let container; 40 | before(function(){ 41 | container = document.createElement("div"); 42 | container.id = "react"; 43 | document.getElementsByTagName('body')[0].appendChild(container); 44 | }); 45 | after(function(){ 46 | document.getElementsByTagName('body')[0].removeChild(container); 47 | emitter._reset(); 48 | }); 49 | it("should choose a version.", co.wrap(function *(){ 50 | let experimentName = UUID.v4(); 51 | let variantNames = []; 52 | for(let i = 0; i < 100; i++) { 53 | variantNames.push(UUID.v4()); 54 | } 55 | let App = React.createClass({ 56 | render: function(){ 57 | return 58 | {variantNames.map(name => { 59 | return
60 | })} 61 |
; 62 | } 63 | }); 64 | yield new Promise(function(resolve, reject){ 65 | ReactDOM.render(, container, resolve); 66 | }); 67 | ReactDOM.unmountComponentAtNode(container); 68 | })); 69 | it("should render the correct variant.", co.wrap(function *(){ 70 | let experimentName = UUID.v4(); 71 | let variantNames = []; 72 | for(let i = 0; i < 100; i++) { 73 | variantNames.push(UUID.v4()); 74 | } 75 | let defaultVariantName = variantNames[Math.floor(Math.random() * variantNames.length)]; 76 | let AppWithdefaultVariantName = React.createClass({ 77 | render: function(){ 78 | return 79 | {variantNames.map(name => { 80 | return
81 | })} 82 |
; 83 | } 84 | }); 85 | let AppWithoutdefaultVariantName = React.createClass({ 86 | render: function(){ 87 | return 88 | {variantNames.map(name => { 89 | return
90 | })} 91 |
; 92 | } 93 | }); 94 | yield new Promise(function(resolve, reject){ 95 | ReactDOM.render(, container, resolve); 96 | }); 97 | let elementWithdefaultVariantName = document.getElementById('variant-' + defaultVariantName); 98 | assert.notEqual(elementWithdefaultVariantName, null); 99 | ReactDOM.unmountComponentAtNode(container); 100 | yield new Promise(function(resolve, reject){ 101 | ReactDOM.render(, container, resolve); 102 | }); 103 | let elementWithoutdefaultVariantName = document.getElementById('variant-' + defaultVariantName); 104 | assert.notEqual(elementWithoutdefaultVariantName, null); 105 | ReactDOM.unmountComponentAtNode(container); 106 | })); 107 | it("should error if variants are added to a experiment after a variant was selected.", co.wrap(function *(){ 108 | let experimentName = UUID.v4(); 109 | let AppPart1 = React.createClass({ 110 | onClickVariant: function(e){ 111 | this.refs.experiment.win(); 112 | }, 113 | render: function(){ 114 | return 115 | A 116 | B 117 | ; 118 | } 119 | }); 120 | let AppPart2 = React.createClass({ 121 | onClickVariant: function(e){ 122 | this.refs.experiment.win(); 123 | }, 124 | render: function(){ 125 | return 126 | C 127 | D 128 | ; 129 | } 130 | }); 131 | yield new Promise(function(resolve, reject){ 132 | ReactDOM.render(, container, resolve); 133 | }); 134 | ReactDOM.unmountComponentAtNode(container); 135 | try { 136 | yield new Promise(function(resolve, reject){ 137 | ReactDOM.render(, container, resolve); 138 | }); 139 | } catch(error) { 140 | if(error.type !== "PUSHTELL_INVALID_VARIANT") { 141 | throw error; 142 | } 143 | ReactDOM.unmountComponentAtNode(container); 144 | return; 145 | } 146 | throw new Error("New variant was added after variant was selected."); 147 | })); 148 | it("should not error if variants are added to a experiment after a variant was selected if variants were defined.", co.wrap(function *(){ 149 | let experimentName = UUID.v4(); 150 | emitter.defineVariants(experimentName, ["A", "B", "C", "D"]); 151 | let AppPart1 = React.createClass({ 152 | onClickVariant: function(e){ 153 | this.refs.experiment.win(); 154 | }, 155 | render: function(){ 156 | return 157 | A 158 | B 159 | ; 160 | } 161 | }); 162 | let AppPart2 = React.createClass({ 163 | onClickVariant: function(e){ 164 | this.refs.experiment.win(); 165 | }, 166 | render: function(){ 167 | return 168 | C 169 | D 170 | ; 171 | } 172 | }); 173 | yield new Promise(function(resolve, reject){ 174 | ReactDOM.render(, container, resolve); 175 | }); 176 | ReactDOM.unmountComponentAtNode(container); 177 | yield new Promise(function(resolve, reject){ 178 | ReactDOM.render(, container, resolve); 179 | }); 180 | ReactDOM.unmountComponentAtNode(container); 181 | })); 182 | it("should error if a variant is added to an experiment after variants were defined.", co.wrap(function *(){ 183 | let experimentName = UUID.v4(); 184 | emitter.defineVariants(experimentName, ["A", "B", "C"]); 185 | let AppPart1 = React.createClass({ 186 | onClickVariant: function(e){ 187 | this.refs.experiment.win(); 188 | }, 189 | render: function(){ 190 | return 191 | A 192 | B 193 | ; 194 | } 195 | }); 196 | let AppPart2 = React.createClass({ 197 | onClickVariant: function(e){ 198 | this.refs.experiment.win(); 199 | }, 200 | render: function(){ 201 | return 202 | C 203 | D 204 | ; 205 | } 206 | }); 207 | yield new Promise(function(resolve, reject){ 208 | ReactDOM.render(, container, resolve); 209 | }); 210 | ReactDOM.unmountComponentAtNode(container); 211 | try { 212 | yield new Promise(function(resolve, reject){ 213 | ReactDOM.render(, container, resolve); 214 | }); 215 | } catch(error) { 216 | if(error.type !== "PUSHTELL_INVALID_VARIANT") { 217 | throw error; 218 | } 219 | ReactDOM.unmountComponentAtNode(container); 220 | return; 221 | } 222 | throw new Error("New variant was added after variants were defined."); 223 | })); 224 | it("should not error if an older test variant is set.", co.wrap(function *(){ 225 | let experimentName = UUID.v4(); 226 | localStorage.setItem("PUSHTELL-" + experimentName, "C"); 227 | let App = React.createClass({ 228 | render: function(){ 229 | return 230 | A 231 | B 232 | ; 233 | } 234 | }); 235 | yield new Promise(function(resolve, reject){ 236 | ReactDOM.render(, container, resolve); 237 | }); 238 | ReactDOM.unmountComponentAtNode(container); 239 | })); 240 | it("should emit when a variant is clicked.", co.wrap(function *(){ 241 | let experimentName = UUID.v4(); 242 | let winningVariantName = null; 243 | let winCallback = function(experimentName, variantName){ 244 | winningVariantName = variantName; 245 | }; 246 | let experimentNameGlobal = null; 247 | let winningVariantNameGlobal = null; 248 | let winCallbackGlobal = function(experimentName, variantName){ 249 | experimentNameGlobal = experimentName; 250 | winningVariantNameGlobal = variantName; 251 | }; 252 | let winSubscription = emitter.addWinListener(experimentName, winCallback); 253 | let winSubscriptionGlobal = emitter.addWinListener(winCallbackGlobal); 254 | let App = React.createClass({ 255 | onClickVariant: function(e){ 256 | this.refs.experiment.win(); 257 | }, 258 | render: function(){ 259 | return 260 | A 261 | B 262 | ; 263 | } 264 | }); 265 | yield new Promise(function(resolve, reject){ 266 | ReactDOM.render(, container, resolve); 267 | }); 268 | let elementA = document.getElementById('variant-a'); 269 | TestUtils.Simulate.click(elementA); 270 | assert.equal(winningVariantName, "A"); 271 | assert.equal(experimentNameGlobal, experimentName); 272 | assert.equal(winningVariantNameGlobal, "A"); 273 | winSubscription.remove(); 274 | winSubscriptionGlobal.remove(); 275 | ReactDOM.unmountComponentAtNode(container); 276 | })); 277 | it("should choose the same variant when a user identifier is defined.", co.wrap(function *(){ 278 | let userIdentifier = UUID.v4(); 279 | let experimentName = UUID.v4(); 280 | let variantNames = []; 281 | for(let i = 0; i < 100; i++) { 282 | variantNames.push(UUID.v4()); 283 | } 284 | let App = React.createClass({ 285 | render: function(){ 286 | return 287 | {variantNames.map(name => { 288 | return
289 | })} 290 |
; 291 | } 292 | }); 293 | let chosenVariant; 294 | emitter.once("play", function(experimentName, variantName){ 295 | chosenVariant = variantName; 296 | }); 297 | yield new Promise(function(resolve, reject){ 298 | ReactDOM.render(, container, resolve); 299 | }); 300 | ReactDOM.unmountComponentAtNode(container); 301 | assert(chosenVariant); 302 | for(let i = 0; i < 100; i++) { 303 | emitter._reset(); 304 | store.clear(); 305 | yield new Promise(function(resolve, reject){ 306 | ReactDOM.render(, container, resolve); 307 | }); 308 | let element = document.getElementById('variant-' + chosenVariant); 309 | assert.notEqual(element, null); 310 | ReactDOM.unmountComponentAtNode(container); 311 | } 312 | })); 313 | }); 314 | -------------------------------------------------------------------------------- /test/browser/helpers.mixpanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import mixpanelHelper from "../../src/helpers/mixpanel.jsx"; 7 | import assert from "assert"; 8 | import co from "co"; 9 | import UUID from "node-uuid"; 10 | import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment'; 11 | import ES6Promise from 'es6-promise'; 12 | ES6Promise.polyfill(); 13 | 14 | describe("Mixpanel Helper", function() { 15 | this.timeout(10000); 16 | let container; 17 | before(co.wrap(function *(){ 18 | container = document.createElement("div"); 19 | container.id = "react"; 20 | document.getElementsByTagName('body')[0].appendChild(container); 21 | })); 22 | after(function(){ 23 | document.getElementsByTagName('body')[0].removeChild(container); 24 | emitter._reset(); 25 | }); 26 | it("should error if Mixpanel global is not set.", function (){ 27 | assert.throws( 28 | function() { 29 | mixpanelHelper.enable(); 30 | }, function(error) { 31 | return error.type === "PUSHTELL_HELPER_MISSING_GLOBAL"; 32 | } 33 | ); 34 | }); 35 | it("should error if Mixpanel is disabled before it is enabled.", function (){ 36 | assert.throws( 37 | function() { 38 | mixpanelHelper.disable(); 39 | }, function(error) { 40 | return error.type === "PUSHTELL_HELPER_INVALID_DISABLE"; 41 | } 42 | ); 43 | }); 44 | it("should report results to Mixpanel.", co.wrap(function *(){ 45 | let playPromise, winPromise; 46 | if(canUseDOM) { 47 | // Mixpanel embed code wrapped in a promise. 48 | yield new Promise(function(resolve, reject){ 49 | (function(e,b){if(!b.__SV){var a,f,i,g;window.mixpanel=b;b._i=[];b.init=function(a,e,d){function f(b,h){var a=h.split(".");2==a.length&&(b=b[a[0]],h=a[1]);b[h]=function(){b.push([h].concat(Array.prototype.slice.call(arguments,0)))}}var c=b;"undefined"!==typeof d?c=b[d]=[]:d="mixpanel";c.people=c.people||[];c.toString=function(b){var a="mixpanel";"mixpanel"!==d&&(a+="."+d);b||(a+=" (stub)");return a};c.people.toString=function(){return c.toString(1)+".people (stub)"};i="disable time_event track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config people.set people.set_once people.increment people.append people.union people.track_charge people.clear_charges people.delete_user".split(" "); 50 | for(g=0;g 78 |
79 |
80 | ; 81 | } 82 | }); 83 | yield new Promise(function(resolve, reject){ 84 | ReactDOM.render(, container, resolve); 85 | }); 86 | yield playPromise; 87 | emitter.emitWin(experimentName); 88 | yield winPromise; 89 | mixpanelHelper.disable(); 90 | ReactDOM.unmountComponentAtNode(container); 91 | if(canUseDOM) { 92 | delete window.mixpanel; 93 | } 94 | })); 95 | }); 96 | 97 | -------------------------------------------------------------------------------- /test/browser/helpers.segment.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import segmentHelper from "../../src/helpers/segment.jsx"; 7 | import assert from "assert"; 8 | import co from "co"; 9 | import UUID from "node-uuid"; 10 | import {canUseDOM} from 'fbjs/lib/ExecutionEnvironment'; 11 | import ES6Promise from 'es6-promise'; 12 | ES6Promise.polyfill(); 13 | 14 | describe("Segment Helper", function() { 15 | this.timeout(10000); 16 | let container; 17 | before(co.wrap(function *(){ 18 | container = document.createElement("div"); 19 | container.id = "react"; 20 | document.getElementsByTagName('body')[0].appendChild(container); 21 | })); 22 | after(function(){ 23 | document.getElementsByTagName('body')[0].removeChild(container); 24 | emitter._reset(); 25 | }); 26 | it("should error if Segment global is not set.", function (){ 27 | assert.throws( 28 | function() { 29 | segmentHelper.enable(); 30 | }, function(error) { 31 | return error.type === "PUSHTELL_HELPER_MISSING_GLOBAL"; 32 | } 33 | ); 34 | }); 35 | it("should error if Segment is disabled before it is enabled.", function (){ 36 | assert.throws( 37 | function() { 38 | segmentHelper.disable(); 39 | }, function(error) { 40 | return error.type === "PUSHTELL_HELPER_INVALID_DISABLE"; 41 | } 42 | ); 43 | }); 44 | it("should report results to Segment.", co.wrap(function *(){ 45 | let playPromise, winPromise; 46 | if(canUseDOM) { 47 | // Segment Analytics.js embed code wrapped in a promise. 48 | yield new Promise(function(resolve, reject){ 49 | !function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","page","once","off","on"];analytics.factory=function(t){return function(){var e=Array.prototype.slice.call(arguments);e.unshift(t);analytics.push(e);return analytics}};for(var t=0;t 79 |
80 |
81 | ; 82 | } 83 | }); 84 | yield new Promise(function(resolve, reject){ 85 | ReactDOM.render(, container, resolve); 86 | }); 87 | yield playPromise; 88 | emitter.emitWin(experimentName); 89 | yield winPromise; 90 | segmentHelper.disable(); 91 | ReactDOM.unmountComponentAtNode(container); 92 | if(canUseDOM) { 93 | delete window.analytics; 94 | } 95 | })); 96 | }); 97 | 98 | -------------------------------------------------------------------------------- /test/browser/karma.conf.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | 3 | module.exports = function (karma) { 4 | var options = { 5 | files: [ 6 | 'tests.bundle.js' 7 | ], 8 | frameworks: ['mocha'], 9 | plugins: [ 10 | 'karma-firefox-launcher', 11 | 'karma-chrome-launcher', 12 | 'karma-mocha', 13 | 'karma-coveralls', 14 | 'karma-coverage', 15 | 'karma-sourcemap-loader', 16 | 'karma-webpack', 17 | 'karma-browserstack-launcher' 18 | ], 19 | preprocessors: { 20 | 'tests.bundle.js': ['webpack', 'sourcemap'] 21 | }, 22 | customLaunchers: { 23 | Chrome_without_localstorage: { 24 | base: 'Chrome', 25 | flags: ['--disable-local-storage'] 26 | }, 27 | bs_windows_7_ie_9: { 28 | base: 'BrowserStack', 29 | os: 'Windows', 30 | os_version: '7', 31 | browser: 'ie', 32 | browser_version : '9.0' 33 | }, 34 | bs_windows_7_ie_10: { 35 | base: 'BrowserStack', 36 | os: 'Windows', 37 | os_version: '7', 38 | browser: 'ie', 39 | browser_version : '10.0' 40 | }, 41 | bs_windows_7_ie_11: { 42 | base: 'BrowserStack', 43 | os: 'Windows', 44 | os_version: '7', 45 | browser: 'ie', 46 | browser_version : '11.0' 47 | }, 48 | bs_windows_7_opera_latest: { 49 | base: 'BrowserStack', 50 | os: 'Windows', 51 | os_version: '7', 52 | browser: 'opera', 53 | browser_version : 'latest' 54 | }, 55 | bs_windows_7_firefox_latest: { 56 | base: 'BrowserStack', 57 | os: 'Windows', 58 | os_version: '7', 59 | browser: 'firefox', 60 | browser_version : 'latest' 61 | }, 62 | bs_windows_7_chrome_latest: { 63 | base: 'BrowserStack', 64 | os: 'Windows', 65 | os_version: '7', 66 | browser: 'chrome', 67 | browser_version : 'latest' 68 | }, 69 | bs_osx_yosemite_safari: { 70 | base: 'BrowserStack', 71 | os: 'OS X', 72 | os_version: 'Yosemite', 73 | browser: 'safari', 74 | browser_version : 'latest' 75 | }, 76 | bs_android_5_default: { 77 | base: 'BrowserStack', 78 | os_version: "5.0", 79 | device: "Google Nexus 5", 80 | browser_version: null, 81 | os: "android", 82 | browser: "android" 83 | }, 84 | bs_ios_8_default: { 85 | base: 'BrowserStack', 86 | os_version: "8.3", 87 | device: "iPhone 6", 88 | browser_version: null, 89 | os: "ios", 90 | browser: "iphone" 91 | } 92 | }, 93 | reporters: ['dots', 'coverage'], 94 | coverageReporter: { 95 | type: 'lcov', 96 | dir: 'coverage/' 97 | }, 98 | singleRun: true, 99 | webpack: { 100 | resolve: { 101 | extensions: ['', '.js', '.jsx'] 102 | }, 103 | devtool: 'inline-source-map', 104 | module: { 105 | loaders: [ 106 | { 107 | test: /\.jsx$/, 108 | exclude: /(node_modules|lib|example)/, 109 | loader: 'babel', 110 | query: { 111 | cacheDirectory: true, 112 | presets: ["stage-1", "es2015", "react"] 113 | } 114 | }, { 115 | exclude: /(node_modules|lib|example)/, 116 | loader: 'regenerator-loader', 117 | test: /\.jsx$/ 118 | }, { 119 | include: path.resolve('src'), 120 | loader: 'isparta', 121 | test: /\.jsx$/ 122 | } 123 | ] 124 | } 125 | }, 126 | browserNoActivityTimeout: 30000, 127 | browserDisconnectTolerance: 2, 128 | webpackMiddleware: { 129 | noInfo: true, 130 | } 131 | }; 132 | if(process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY) { 133 | options.browserStack = { 134 | username: process.env.BROWSERSTACK_USERNAME, 135 | accessKey: process.env.BROWSERSTACK_ACCESS_KEY, 136 | pollingTimeout: 10000 137 | }; 138 | options.browsers = Object.keys(options.customLaunchers).filter(function(key){ 139 | return key.indexOf("bs_") !== -1; 140 | }); 141 | } else { 142 | options.browsers = ['Chrome']; 143 | } 144 | if(process.env.COVERALLS_REPO_TOKEN) { 145 | options.reporters.push('coveralls'); 146 | } 147 | karma.set(options); 148 | }; -------------------------------------------------------------------------------- /test/browser/tests.bundle.js: -------------------------------------------------------------------------------- 1 | var ctx = require.context('.', true, /.+\.test\.jsx?$/); 2 | require.context('.', true, /.+\.test\.jsx?$/).keys().forEach(ctx); 3 | module.exports = ctx; -------------------------------------------------------------------------------- /test/browser/variant.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/CoreExperiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import co from "co"; 8 | import UUID from "node-uuid"; 9 | import ES6Promise from 'es6-promise'; 10 | ES6Promise.polyfill(); 11 | 12 | describe("Variant", function() { 13 | let container; 14 | before(function(){ 15 | container = document.createElement("div"); 16 | container.id = "react"; 17 | document.getElementsByTagName('body')[0].appendChild(container); 18 | }); 19 | after(function(){ 20 | document.getElementsByTagName('body')[0].removeChild(container); 21 | emitter._reset(); 22 | }); 23 | it("should render text nodes.", co.wrap(function *(){ 24 | let experimentName = UUID.v4(); 25 | let variantTextA = UUID.v4(); 26 | let variantTextB = UUID.v4(); 27 | let App = React.createClass({ 28 | render: function(){ 29 | return 30 | {variantTextA} 31 | {variantTextB} 32 | ; 33 | } 34 | }); 35 | yield new Promise(function(resolve, reject){ 36 | ReactDOM.render(, container, resolve); 37 | }); 38 | assert.notEqual(container.innerHTML.indexOf(variantTextA), null); 39 | })); 40 | it("should render components.", co.wrap(function *(){ 41 | let experimentName = UUID.v4(); 42 | let App = React.createClass({ 43 | render: function(){ 44 | return 45 |
46 |
47 | ; 48 | } 49 | }); 50 | yield new Promise(function(resolve, reject){ 51 | ReactDOM.render(, container, resolve); 52 | }); 53 | let elementA = document.getElementById('variant-a'); 54 | let elementB = document.getElementById('variant-b'); 55 | assert.notEqual(elementA, null); 56 | assert.equal(elementB, null); 57 | ReactDOM.unmountComponentAtNode(container); 58 | })); 59 | it("should render arrays of components.", co.wrap(function *(){ 60 | let experimentName = UUID.v4(); 61 | let App = React.createClass({ 62 | render: function(){ 63 | return 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | ; 73 | } 74 | }); 75 | yield new Promise(function(resolve, reject){ 76 | ReactDOM.render(, container, resolve); 77 | }); 78 | let elementA = document.getElementById('variant-a'); 79 | let elementB = document.getElementById('variant-b'); 80 | assert.notEqual(elementA, null); 81 | assert.equal(elementB, null); 82 | ReactDOM.unmountComponentAtNode(container); 83 | })); 84 | }); 85 | 86 | -------------------------------------------------------------------------------- /test/browser/weighted.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Experiment from "../../src/Experiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import co from "co"; 8 | import UUID from "node-uuid"; 9 | import TestUtils from 'react-dom/test-utils'; 10 | import ES6Promise from 'es6-promise'; 11 | ES6Promise.polyfill(); 12 | 13 | let store; 14 | 15 | let noopStore = { 16 | getItem: function(){}, 17 | setItem: function(){}, 18 | clear: function(){} 19 | }; 20 | 21 | if(typeof window !== 'undefined' && 'localStorage' in window && window['localStorage'] !== null) { 22 | try { 23 | let key = '__pushtell_react__'; 24 | window.localStorage.setItem(key, key); 25 | if (window.localStorage.getItem(key) !== key) { 26 | store = noopStore; 27 | } else { 28 | window.localStorage.removeItem(key); 29 | store = window.localStorage; 30 | } 31 | } catch(e) { 32 | store = noopStore; 33 | } 34 | } else { 35 | store = noopStore; 36 | } 37 | 38 | function add(a, b) { 39 | return a + b; 40 | } 41 | 42 | describe("Weighted Experiment", function() { 43 | this.timeout(10000); 44 | let container; 45 | before(function(){ 46 | container = document.createElement("div"); 47 | container.id = "react"; 48 | document.getElementsByTagName('body')[0].appendChild(container); 49 | }); 50 | after(function(){ 51 | document.getElementsByTagName('body')[0].removeChild(container); 52 | emitter._reset(); 53 | }); 54 | it("should choose a weighted variants.", co.wrap(function *(){ 55 | const experimentName = UUID.v4(); 56 | const variantNames = []; 57 | const variantWeights = []; 58 | const playCount = {}; 59 | for(let i = 0; i < 5; i++) { 60 | variantNames.push(UUID.v4()); 61 | variantWeights.push(Math.floor(Math.random() * 100)); 62 | } 63 | const weightSum = variantWeights.reduce(add, 0); 64 | emitter.defineVariants(experimentName, variantNames, variantWeights); 65 | assert.equal(emitter.getSortedVariantWeights(experimentName).reduce(add, 0), weightSum); 66 | let App = React.createClass({ 67 | render: function(){ 68 | return 69 | {variantNames.map(name => { 70 | return
71 | })} 72 |
; 73 | } 74 | }); 75 | let chosenVariant; 76 | emitter.addListener("play", function(experimentName, variantName){ 77 | playCount[variantName] = playCount[variantName] || 0; 78 | playCount[variantName] += 1; 79 | }); 80 | for(let i = 0; i < 1000; i++) { 81 | yield new Promise(function(resolve, reject){ 82 | ReactDOM.render(, container, resolve); 83 | }); 84 | ReactDOM.unmountComponentAtNode(container); 85 | store.clear(); 86 | emitter._resetPlayedExperiments(); 87 | } 88 | const playSum = Object.keys(playCount).map(function(variantName){ 89 | return playCount[variantName] || 0; 90 | }).reduce(add, 0); 91 | const playCountToWeightRatios = variantNames.map(function(variantName, index){ 92 | return (playCount[variantName] / playSum) / (variantWeights[index] / weightSum) 93 | }); 94 | const ratioMean = playCountToWeightRatios.reduce(add, 0) / playCountToWeightRatios.length; 95 | const ratioVariance = playCountToWeightRatios.map(function(ratio){ 96 | return Math.pow(ratioMean - ratio, 2); 97 | }).reduce(add, 0); 98 | const ratioStandardDeviation = Math.sqrt(ratioVariance); 99 | assert(ratioStandardDeviation < 0.6); 100 | })); 101 | }); 102 | -------------------------------------------------------------------------------- /test/isomorphic/experiment.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOMServer from "react-dom/server"; 3 | import Experiment from "../../src/Experiment.jsx"; 4 | import Variant from "../../src/Variant.jsx"; 5 | import emitter from "../../src/emitter.jsx"; 6 | import assert from "assert"; 7 | import { expect } from "chai"; 8 | import UUID from "node-uuid"; 9 | 10 | const renderApp = (experimentName, variantNames, userIdentifier) => { 11 | return () => 12 | 13 | {variantNames.map(name => { 14 | return
15 | })} 16 |
17 | } 18 | 19 | describe("Experiment", () => { 20 | 21 | 22 | it("should render to a string.", () => { 23 | const experimentName = UUID.v4(); 24 | const App = () => 25 | 26 |
27 |
28 | 29 | 30 | const result = ReactDOMServer.renderToString(); 31 | expect(result).to.be.a('string'); 32 | }); 33 | it("should choose the same variant when a user identifier is defined.", () => { 34 | const userIdentifier = UUID.v4(); 35 | const experimentName = UUID.v4(); 36 | const variantNames = []; 37 | for(let i = 0; i < 100; i++) { 38 | variantNames.push(UUID.v4()); 39 | } 40 | 41 | const App = renderApp(experimentName, variantNames, userIdentifier); 42 | 43 | let chosenVariant; 44 | emitter.once("play", (experimentName, variantName) => { 45 | chosenVariant = variantName; 46 | }); 47 | 48 | const result = ReactDOMServer.renderToString(); 49 | assert(chosenVariant); 50 | 51 | assert.notEqual(result.indexOf(chosenVariant), -1); 52 | for(let i = 0; i < 100; i++) { 53 | emitter._reset(); 54 | const res = ReactDOMServer.renderToString(); 55 | assert.notEqual(res.indexOf(chosenVariant), -1); 56 | } 57 | }); 58 | it("should render different variants with different user identifiers.", () => { 59 | const userIdentifier = UUID.v4(); 60 | const user2Identifier = UUID.v4(); 61 | const experimentName = UUID.v4(); 62 | const variantNames = []; 63 | for(let i = 0; i < 100; i++) { 64 | variantNames.push(UUID.v4()); 65 | } 66 | const FirstRender = renderApp(experimentName, variantNames, userIdentifier); 67 | const SecondRender = renderApp(experimentName, variantNames, user2Identifier); 68 | 69 | const result1 = ReactDOMServer.renderToString(); 70 | const result2 = ReactDOMServer.renderToString(); 71 | 72 | assert.notEqual(result1, result2); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /webpack.standalone.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | 3 | module.exports = { 4 | entry: { 5 | index: './index.js' 6 | }, 7 | output: { 8 | path: './', 9 | filename: 'standalone.min.js' 10 | }, 11 | module: { 12 | loaders: [ 13 | { 14 | test: /\.jsx?$/, 15 | exclude: /(node_modules)/, 16 | loader: 'babel', 17 | query: { 18 | cacheDirectory: true, 19 | presets: ["stage-1", "es2015", "react"], 20 | plugins: ["add-module-exports"] 21 | } 22 | }, { 23 | exclude: /node_modules/, 24 | loader: 'regenerator-loader', 25 | test: /\.jsx$/ 26 | }, { 27 | test: require.resolve("./lib/Experiment"), 28 | loader: "expose?Experiment" 29 | }, { 30 | test: require.resolve("./lib/Variant"), 31 | loader: "expose?Variant" 32 | }, { 33 | test: require.resolve("./lib/emitter"), 34 | loader: "expose?emitter" 35 | }, { 36 | test: require.resolve("./lib/debugger"), 37 | loader: "expose?experimentDebugger" 38 | }, { 39 | test: require.resolve("./lib/helpers/mixpanel"), 40 | loader: "expose?mixpanelHelper" 41 | }, { 42 | test: require.resolve("./lib/helpers/segment"), 43 | loader: "expose?segmentHelper" 44 | } 45 | ], 46 | postLoaders: [ 47 | { 48 | loader: "transform?envify" 49 | } 50 | ], 51 | plugins: [ 52 | new webpack.optimize.UglifyJsPlugin() 53 | ] 54 | }, 55 | externals: { 56 | react: 'React', 57 | 'react-dom': "ReactDOM" 58 | } 59 | }; --------------------------------------------------------------------------------