├── .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 | [](https://www.npmjs.com/package/react-ab-test)
4 | [](https://circleci.com/gh/pushtell/react-ab-test)
5 | [](https://coveralls.io/github/pushtell/react-ab-test?branch=master)
6 | [](https://david-dm.org/pushtell/react-ab-test)
7 | [](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 |
Emit a win
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 Emit a win ;
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 |
Emit a win
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 |
Emit a win
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 |
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 |
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 ;
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 | };
--------------------------------------------------------------------------------