├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── History.md ├── Makefile ├── README.md ├── lib ├── abtest.js └── random.js ├── package.json └── test └── abtest.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.seed 2 | *.log 3 | *.csv 4 | *.dat 5 | *.out 6 | *.pid 7 | *.gz 8 | 9 | coverage.html 10 | coverage/ 11 | cov/ 12 | 13 | node_modules 14 | 15 | dump.rdb 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | benchmark/ 2 | test/ 3 | cov/ 4 | Makefile 5 | covrage.html 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | script: "make test-cov" 5 | after_script: "npm install coveralls@2.10.0 && cat ./coverage/lcov.info | coveralls" 6 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 1.2.0 / 2016-07-29 3 | ================== 4 | 5 | * feat: add a custom parameter configuration for query (#1) 6 | 7 | 1.1.0 / 2014-11-08 8 | ================== 9 | 10 | * pin autod@1, update mocha 11 | * add crc, insure buckets change let cookie expire 12 | 13 | 1.0.2 / 2014-09-30 14 | ================== 15 | 16 | * remove return this 17 | 18 | 1.0.1 / 2014-09-30 19 | ================== 20 | 21 | * less strict 22 | 23 | 1.0.0 / 2014-09-30 24 | ================== 25 | 26 | * init 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = test/*.test.js 2 | REPORTER = spec 3 | TIMEOUT = 10000 4 | MOCHA_OPTS = 5 | 6 | install: 7 | @npm install --registry=http://registry.npm.taobao.org 8 | 9 | test: install 10 | @NODE_ENV=test ./node_modules/.bin/mocha --harmony\ 11 | --reporter $(REPORTER) \ 12 | --timeout $(TIMEOUT) \ 13 | --require should \ 14 | $(MOCHA_OPTS) \ 15 | $(TESTS) 16 | 17 | test-cov cov: install 18 | @-NODE_ENV=test node --harmony\ 19 | node_modules/.bin/istanbul cover --preserve-comments \ 20 | ./node_modules/.bin/_mocha \ 21 | -- -u exports \ 22 | --reporter $(REPORTER) \ 23 | --timeout $(TIMEOUT) \ 24 | --require should \ 25 | $(MOCHA_OPTS) \ 26 | $(TESTS) 27 | 28 | test-all: install test cov 29 | 30 | autod: install 31 | @./node_modules/.bin/autod -w --prefix "~" \ 32 | -D mocha,should,istanbul-harmony 33 | @$(MAKE) install 34 | 35 | .PHONY: test 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | abtest 2 | --------------- 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | [![David deps][david-image]][david-url] 8 | [![node version][node-image]][node-url] 9 | [![Gittip][gittip-image]][gittip-url] 10 | 11 | [npm-image]: https://img.shields.io/npm/v/abtest.svg?style=flat-square 12 | [npm-url]: https://npmjs.org/package/abtest 13 | [travis-image]: https://img.shields.io/travis/node-modules/abtest.svg?style=flat-square 14 | [travis-url]: https://travis-ci.org/node-modules/abtest 15 | [coveralls-image]: https://img.shields.io/coveralls/node-modules/abtest.svg?style=flat-square 16 | [coveralls-url]: https://coveralls.io/r/node-modules/abtest?branch=master 17 | [david-image]: https://img.shields.io/david/node-modules/abtest.svg?style=flat-square 18 | [david-url]: https://david-dm.org/node-modules/abtest 19 | [node-image]: https://img.shields.io/badge/node.js-%3E=_0.10-green.svg?style=flat-square 20 | [node-url]: http://nodejs.org/download/ 21 | [gittip-image]: https://img.shields.io/gittip/dead-horse.svg?style=flat-square 22 | [gittip-url]: https://www.gittip.com/dead-horse/ 23 | 24 | an A/B test client for node web 25 | 26 | ## Installation 27 | 28 | ```bash 29 | $ npm install abtest 30 | ``` 31 | 32 | ## Feature 33 | 34 | - Random split user into different buckets. 35 | - Record user's bucket in cookie. 36 | - Force choose bucket by query. 37 | - Expire cookie when buckets changed. 38 | 39 | ## Usage 40 | 41 | use with koa: 42 | 43 | ```js 44 | var ABTest = ABTest(); 45 | var app = koa(); 46 | 47 | app.use(function* (next) { 48 | this.abtest = ABTest({ 49 | getCookie: function () {}, // custom your getCookie method 50 | setCookie: function () {}, // custom your setCookie method 51 | query: this.query 52 | }); 53 | }); 54 | 55 | app.use(function* (next) { 56 | this.abtest.configure({ 57 | bucket: { 58 | a: 9, 59 | b: 1 60 | }, 61 | enableQuery: true, 62 | enableCookie: true 63 | }); 64 | }); 65 | 66 | app.use(function* (next) { 67 | this.body = this.abtest.bucket; // 10% a, 90% b 68 | }); 69 | ``` 70 | 71 | ### License 72 | 73 | MIT 74 | -------------------------------------------------------------------------------- /lib/abtest.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * abtest - lib/abtest.js 3 | */ 4 | 5 | 'use strict'; 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var debug = require('debug')('abtest'); 12 | var assert = require('assert'); 13 | var crc = require('crc').crc32; 14 | 15 | /** 16 | * Module exports 17 | */ 18 | 19 | module.exports = ABTest; 20 | 21 | /** 22 | * AB test 23 | * @param {Object} ctx 24 | * {Function} getCookie(key) 25 | * {Function} setCookie(key, value) 26 | * {Object} query 27 | */ 28 | function ABTest(ctx) { 29 | ctx = ctx || {}; 30 | if (!(this instanceof ABTest)) return new ABTest(ctx); 31 | 32 | this.ctx = ctx; 33 | this.cookie = 'abtest'; 34 | this.query = 'abtest'; 35 | this.method = require('./random'); 36 | this.buckets = {}; 37 | this.crc = hash(this.buckets); 38 | this.enableCookie = true; 39 | this.enableQuery = true; 40 | this.defaultBucket = null; 41 | this._bucket = null; 42 | } 43 | 44 | var KEYS = [ 45 | 'cookie', 46 | 'query', 47 | 'method', 48 | 'buckets', 49 | 'enableCookie', 50 | 'enableQuery', 51 | 'defaultBucket' 52 | ]; 53 | 54 | /** 55 | * configure the ab test instance 56 | * @param {Object} config 57 | * - {Object} buckets 58 | * - {String} [cookie] cookie name, default to 'abtest' 59 | * - {Function} [method] method, default to 'random' 60 | * - {String} [defaultBucket] default bucket name 61 | * - {Boolean} [enable] enable abtest, default to true 62 | * - {Boolean} [enableCookie] enable set cookie, default to true 63 | * - {Boolean} [enableQuery] enable get from query, default to true 64 | * @return {ABTest} 65 | */ 66 | 67 | ABTest.prototype.configure = function (config) { 68 | if (!config) { 69 | return; 70 | } 71 | 72 | var abtest = this; 73 | 74 | KEYS.forEach(function (key) { 75 | if (!config.hasOwnProperty(key)) return; 76 | 77 | abtest[key] = config[key]; 78 | // set crc 79 | if (key === 'buckets') abtest.crc = hash(abtest.buckets); 80 | }); 81 | }; 82 | 83 | ABTest.prototype.fromQuery = function () { 84 | if (!this.enableQuery) return null; 85 | if (!this.ctx.query) return null; 86 | var query = this.ctx.query; 87 | var bucket = query[this.query]; 88 | // not valid 89 | if (!this.buckets.hasOwnProperty(bucket)) return null; 90 | debug('get bucket `%s` from query', bucket); 91 | return bucket; 92 | }; 93 | 94 | ABTest.prototype.fromCookie = function () { 95 | if (!this.enableCookie) return null; 96 | if (!this.ctx.getCookie) return null; 97 | 98 | var cookie = this.ctx.getCookie(this.cookie); 99 | // can not get cookie 100 | if (!cookie) return null; 101 | 102 | cookie = cookie.split(':'); 103 | var bucket = cookie[0]; 104 | var crcInfo = cookie[1]; 105 | // buckets changed 106 | if (crcInfo !== this.crc) return null; 107 | // not valid 108 | if (!this.buckets.hasOwnProperty(bucket)) return null; 109 | 110 | debug('get bucket `%s` from cookie', bucket); 111 | return bucket; 112 | }; 113 | 114 | ABTest.prototype.fromMethod = function () { 115 | var bucket = this.method(this.buckets); 116 | debug('get bucket `%s` from method', bucket); 117 | return bucket; 118 | }; 119 | 120 | Object.defineProperty(ABTest.prototype, 'bucket', { 121 | get: function () { 122 | if (this._bucket) { 123 | debug('already got bucket: %s', this._bucket); 124 | return this._bucket; 125 | } 126 | this._bucket = this.fromQuery() || this.fromCookie(); 127 | 128 | if (!this._bucket) { 129 | this._bucket = this.fromMethod() || this.defaultBucket; 130 | if (this.enableCookie && this.ctx.setCookie) { 131 | var cookie = this._bucket + ':' + this.crc; 132 | debug('set cookie %s=%s', this.cookie, cookie); 133 | this.ctx.setCookie(this.cookie, cookie); 134 | } 135 | } 136 | debug('get the final bucket: `%s`', this._bucket); 137 | return this._bucket; 138 | } 139 | }); 140 | 141 | /** 142 | * hash buckets into a seperate string 143 | * @param {Object} buckets 144 | * @return {String} 145 | */ 146 | 147 | function hash(buckets) { 148 | return String(crc(JSON.stringify(buckets))); 149 | } 150 | -------------------------------------------------------------------------------- /lib/random.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * abtest - lib/random.js 3 | */ 4 | 5 | 'use strict'; 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var debug = require('debug')('abtest:random'); 12 | 13 | /** 14 | * simple random 15 | * @param {Obejct} buckets 16 | * { 17 | * [name1]: [radio1], 18 | * [name2]: [radio2] 19 | * } 20 | * @return {String} name 21 | */ 22 | module.exports = function(buckets) { 23 | var total = 0; 24 | var sections = []; 25 | for (var key in buckets) { 26 | total += buckets[key]; 27 | sections.push([total, key]); 28 | } 29 | debug('get sections %j for buckets %j', sections, buckets); 30 | 31 | var num = Math.random() * total; 32 | // force num never equal 0 33 | if (num === 0) num = 0.000001; 34 | debug('get random number %d', num); 35 | 36 | for (var i = 0; i < sections.length; i++) { 37 | var section = sections[i]; 38 | if (num <= section[0]) { 39 | debug('get bucket %s', section[1]); 40 | return section[1]; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abtest", 3 | "version": "1.2.0", 4 | "description": "an A/B test client for node web", 5 | "main": "lib/abtest.js", 6 | "files": [ 7 | "lib", 8 | "LICENSE", 9 | "History.md" 10 | ], 11 | "scripts": { 12 | "test": "make test" 13 | }, 14 | "keywords": [ 15 | "web", 16 | "AB testing" 17 | ], 18 | "author": { 19 | "name": "dead-horse", 20 | "email": "dead_horse@qq.com", 21 | "url": "http://deadhorse.me" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git@github.com:node-modules/abtest" 26 | }, 27 | "license": "MIT", 28 | "dependencies": { 29 | "crc": "~3.4.0", 30 | "debug": "~2.6.7" 31 | }, 32 | "devDependencies": { 33 | "autod": "2.6.1", 34 | "istanbul-harmony": "~0.3.16", 35 | "koa": "~1.2.1", 36 | "mocha": "~2.5.3", 37 | "should": "~10.0.0", 38 | "supertest": "~1.2.0" 39 | }, 40 | "engine": { 41 | "node": ">=0.10" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/abtest.test.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * abtest - test/abtest.test.js 3 | */ 4 | 5 | 'use strict'; 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var request = require('supertest'); 12 | var crc = require('crc').crc32; 13 | var ABTest = require('..'); 14 | var koa = require('koa'); 15 | 16 | describe('ABTest', function () { 17 | describe('with koa', function () { 18 | it('should have bucket', function (done) { 19 | var app = App({ 20 | buckets: { 21 | a: 5, 22 | b: 5 23 | } 24 | }); 25 | 26 | request(app) 27 | .get('/') 28 | .expect(200) 29 | .end(function (err, res) { 30 | var cookie = res.headers['set-cookie']; 31 | var body = res.text.toString(); 32 | cookie[0].should.match(new RegExp('abtest=' + body + ':(\\d)+')); 33 | done(err); 34 | }); 35 | }); 36 | 37 | it('should get bucket from cookie', function (done) { 38 | var buckets = { 39 | a: 0, 40 | b: 10 41 | }; 42 | var app = App({ 43 | buckets: buckets 44 | }); 45 | var crcstr = crc(JSON.stringify(buckets)); 46 | 47 | request(app) 48 | .get('/') 49 | .set('cookie', 'abtest=a:' + crcstr) 50 | .expect(200) 51 | .expect('a', done); 52 | }); 53 | 54 | it('should get bucket from query', function (done) { 55 | var buckets = { 56 | a: 0, 57 | b: 10 58 | }; 59 | var app = App({ 60 | buckets: buckets 61 | }); 62 | var crcstr = crc(JSON.stringify(buckets)); 63 | 64 | request(app) 65 | .get('/path?abtest=a') 66 | .set('cookie', 'abtest=b:' + crcstr) 67 | .expect(200) 68 | .expect('a', done); 69 | }); 70 | 71 | it('should get bucket from custom query parameter', function (done) { 72 | var buckets = { 73 | a: 0, 74 | b: 10 75 | }; 76 | var app = App({ 77 | buckets: buckets, 78 | query: 'x_abtest', 79 | }); 80 | var crcstr = crc(JSON.stringify(buckets)); 81 | 82 | request(app) 83 | .get('/path?x_abtest=a') 84 | .set('cookie', 'abtest=b:' + crcstr) 85 | .expect(200) 86 | .expect('a', done); 87 | }); 88 | 89 | it('should get bucket from method when cookie and query invalid', function (done) { 90 | var app = App({ 91 | buckets: { 92 | a: 0, 93 | b: 10 94 | } 95 | }); 96 | 97 | request(app) 98 | .get('/path?abtest=c') 99 | .set('cookie', 'abtest=c') 100 | .expect(200) 101 | .expect('b', done); 102 | }); 103 | 104 | it('should get bucket from method when cookie crc not match', function (done) { 105 | var app = App({ 106 | buckets: { 107 | a: 0, 108 | b: 10 109 | } 110 | }); 111 | 112 | request(app) 113 | .get('/path') 114 | .set('cookie', 'abtest=a:xxxx') 115 | .expect(200) 116 | .expect('b', done); 117 | }); 118 | 119 | it('should get bucket undefined when configure without bucket', function (done) { 120 | var app = App(); 121 | request(app) 122 | .get('/') 123 | .expect(204, done); 124 | }); 125 | 126 | it('should ignore query when enableQuery = false', function (done) { 127 | var app = App({ 128 | buckets: { 129 | a: 0, 130 | b: 10 131 | }, 132 | enableQuery: false 133 | }); 134 | request(app) 135 | .get('/path?abtest=a') 136 | .expect('b', done); 137 | }); 138 | 139 | it('should ignore cookie when enableCookie = false', function (done) { 140 | var app = App({ 141 | buckets: { 142 | a: 0, 143 | b: 10 144 | }, 145 | enableCookie: false 146 | }); 147 | request(app) 148 | .get('/') 149 | .set('cookie', 'abtest=a') 150 | .expect('b', function (err, res) { 151 | (res.headers['set-cookie'] === undefined).should.be.ok; 152 | done(err); 153 | }); 154 | }); 155 | }); 156 | }); 157 | 158 | function App(config) { 159 | var app = koa(); 160 | app.use(function* () { 161 | this.abtest = ABTest({ 162 | query: this.query, 163 | getCookie: this.cookies.get.bind(this.cookies), 164 | setCookie: this.cookies.set.bind(this.cookies) 165 | }); 166 | this.abtest.configure(config); 167 | this.body = this.abtest.bucket; 168 | this.body = this.abtest.bucket; 169 | }); 170 | return app.listen(); 171 | } 172 | --------------------------------------------------------------------------------